Skip to content
View in the app

A better way to browse. Learn more.

DoniaWeB

A full-screen app on your home screen with push notifications, badges and more.

To install this app on iOS and iPadOS
  1. Tap the Share icon in Safari
  2. Scroll the menu and tap Add to Home Screen.
  3. Tap Add in the top-right corner.
To install this app on Android
  1. Tap the 3-dot menu (⋮) in the top-right corner of the browser.
  2. Tap Add to Home screen or Install app.
  3. Confirm by tapping Install.
     

Mahmoud

Administrators
  • Joined

  • Last visited

Blog Entries posted by Mahmoud

  1. MPWA WHMCS WhatsApp Notifications

    📢 MPWA WHMCS WhatsApp Notifications Integration Guide
    Managing client communication efficiently is a cornerstone of any successful business. With the MPWA WHMCS WhatsApp Notification addon, you can instantly connect with your clients via WhatsApp and keep them updated about every critical event in your WHMCS system.
    ✨ Key Features
    With this addon, you can send real-time WhatsApp notifications for a wide range of actions, including:
    🔑 Client Sign-In Notice
    📝 New Client Registration Alert
    🔒 Client Password Update Warning
    ⚙️ Customizable Message Templates
    🧾 Invoice Generation Alert
    💳 Payment Confirmation Notice
    ⏰ Invoice Due Reminder
    🚨 Overdue Invoice Alerts (First, Second, and Final)
    ✅ Service Activation Notice
    ⏸️ Service Suspension Alert
    🔄 Service Reactivation Notice
    🗑️ Service Termination Warning
    🔑 Service Password Modification Alert
    ❌ Cancellation Request Update
    🌐 Domain Registration Confirmation
    🔁 Domain Transfer Notice
    🔔 Domain Renewal Reminder
    📅 Domain Overdue Alerts (1st, 2nd, and 3rd Tier)
    🎟️ Support Ticket Reply via WhatsApp
    👤 Personalized Messages using the registered WHMCS phone number
    🛠️ Easy Installation Guide
    Follow these simple steps to get started:
    🔹 Step 1: Create Your MPWA Account
    Go to mpwa.to and sign up.
    🔹 Step 2: Add Your Device
    From the Dashboard, click Add New Device.
    Enter your WhatsApp phone number.
    🔹 Step 3: Pair Your Device
    Scan the QR code displayed on the screen to link your WhatsApp.
    🔹 Step 4: Upload the Addon:
    MPWA_WHMCS.zip
    Upload the addon files to your WHMCS server under:
    /modules/addons 🔹 Step 5: Activate the Addon
    From the WHMCS backend, go to:
    Settings → Apps & Integration OR Addon Modules.
    Activate MPWA WHMCS Module.
    🔹 Step 6: Assign Administrator Rights
    Assign Full Administrator rights for the module.
    🔹 Step 7: Configure API Settings
    In WHMCS, go to:
    Addons → MPWA WHMCS Module
    Paste the following:
    API Key (from your mpwa.to account)
    Sender Phone Number (with country code, but without +)
    Example:
    201234567890 🔹 Step 8: Enjoy!
    That’s it! 🚀 Your WHMCS is now integrated with MPWA WhatsApp Notifications.
    Test by creating an invoice, updating a service, or sending a support ticket reply, and watch your WhatsApp light up with real-time alerts.
    🎯 Final Thoughts
    Integrating MPWA WhatsApp Notifications with WHMCS enhances client engagement, improves payment reminders, and ensures no important update is missed. With an easy setup and a wide range of supported alerts, this addon is a must-have for businesses running on WHMCS.
  2. UltimatePOS WhatsApp Notification Integration

    📢 How to Enable WhatsApp Notifications in UltimatePOS
    Are you tired of relying only on SMS for notifications in UltimatePOS?
    With just a few steps, you can integrate WhatsApp notifications using mpwa.to and receive instant alerts directly on your WhatsApp account.
    ✨ Why WhatsApp Notifications?
    Faster and more reliable than SMS.
    Works globally without extra carrier charges.
    Easy setup with API integration.
    Perfect for invoices, sales alerts, stock updates, and more.
    📝 Step-by-Step Setup
    🔹 Step 1: Go to SMS Settings
    In your UltimatePOS Dashboard:
    Navigate to Settings → SMS Settings.


    🔹 Step 2: Select SMS Service
    Under SMS Service, choose Other.


    🔹 Step 3: Configure API Settings
    Fill in the fields with the following details:
    URL:
    https://mpwa.to/send-message Send to parameter name:
    number Message parameter name:
    message Request Method:
    POST ➕ Additional Parameters:
    Parameter 1 key:
    api_key Parameter 1 value:
    YOUR-MPWA-API-KEY (from your mpwa.to account)
    Parameter 2 key:
    sender Parameter 2 value:
    Your device/phone number with country code (without +)
    Example:
    201234567890

    🔹 Step 4: Test the Connection
    Scroll down to the Testing section.
    Enter your WhatsApp number.
    Click Send Test SMS.
    If everything is set up correctly, 🎉 you’ll instantly receive a test message on your WhatsApp.


    🎯 Final Thoughts
    That’s it! With just a few configurations, you can easily enable WhatsApp Notifications in UltimatePOS using the mpwa.to API.
    This setup ensures that you never miss important updates about your sales, invoices, or alerts again.
  3. Multiple Barcodes for Products

    Multiple Barcodes for Products - Complete Implementation Guide
    Overview
    This guide will walk you through the complete process of adding multiple barcodes functionality to your Ultimate POS products. This feature allows you to:
    Assign multiple barcodes to products (in addition to the main SKU)
    Search products by any of their barcodes in POS, Purchase, and Universal Search
    Select which barcode to print on labels
    Support different barcode types (C128, C39, EAN-13, etc.) for each barcode
    Add optional descriptions to identify each barcode (e.g., "Supplier barcode", "Internal code")
    This is useful when:
    Products come with manufacturer barcodes but you also use internal codes
    Suppliers provide their own barcode/SKU that differs from yours
    You need to support legacy barcodes after rebranding

    Download
    multi-barcode-products.zip
    Step 1: Database Structure
    Migration
    Create a new migration for the product_barcodes table:
    php artisan make:migration create_product_barcodes_table Migration Content:
    <?php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { /** * Run the migrations. * * @return void */ public function up() { Schema::create('product_barcodes', function (Blueprint $table) { $table->id(); $table->unsignedInteger('product_id'); $table->string('barcode', 191); $table->string('barcode_type', 20)->default('C128'); $table->string('description', 191)->nullable(); $table->timestamps(); $table->foreign('product_id')->references('id')->on('products')->onDelete('cascade'); $table->index('barcode'); $table->unique(['product_id', 'barcode']); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('product_barcodes'); } }; Direct SQL (Alternative):
    CREATE TABLE `product_barcodes` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `product_id` INT UNSIGNED NOT NULL, `barcode` VARCHAR(191) NOT NULL, `barcode_type` VARCHAR(20) NOT NULL DEFAULT 'C128', `description` VARCHAR(191) NULL, `created_at` TIMESTAMP NULL, `updated_at` TIMESTAMP NULL, CONSTRAINT `fk_product_barcodes_product_id` FOREIGN KEY (`product_id`) REFERENCES `products`(`id`) ON DELETE CASCADE, UNIQUE KEY `unique_product_barcode` (`product_id`, `barcode`), INDEX `idx_product_barcodes_barcode` (`barcode`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; Run Migration:
    php artisan migrate Step 2: Create ProductBarcode Model
    Create File: app/ProductBarcode.php
    <?php namespace App; use Illuminate\Database\Eloquent\Model; class ProductBarcode extends Model { /** * The attributes that aren't mass assignable. * * @var array */ protected $guarded = ['id']; /** * Get the product that owns the barcode. */ public function product() { return $this->belongsTo(\App\Product::class); } } Step 3: Update Product Model
    File: app/Product.php
    Add the relationship method at the end of the class (before the closing }):
    /** * Get the additional barcodes for the product. */ public function barcodes() { return $this->hasMany(\App\ProductBarcode::class); } Step 4: Update ProductUtil
    File: app/Utils/ProductUtil.php
    4.1 Add syncProductBarcodes Method
    Add this method after the generateSubSku() method:
    /** * Sync product barcodes. * * @param \App\Product $product * @param array $barcodes * @return void */ public function syncProductBarcodes($product, $barcodes) { // Delete existing barcodes $product->barcodes()->delete(); // Add new barcodes if (!empty($barcodes)) { foreach ($barcodes as $barcode) { if (!empty($barcode['barcode'])) { $product->barcodes()->create([ 'barcode' => $barcode['barcode'], 'barcode_type' => $barcode['barcode_type'] ?? 'C128', 'description' => $barcode['description'] ?? null, ]); } } } } 4.2 Update filterProduct Method for Search
    In the filterProduct() method, find the LIKE search block and add barcode search after sub_sku:
    if (in_array('sub_sku', $search_fields)) { $query->orWhere('sub_sku', 'like', '%'.$search_term.'%'); } // Search in product_barcodes table if (in_array('sku', $search_fields) || in_array('sub_sku', $search_fields)) { $query->orWhereExists(function ($subquery) use ($search_term) { $subquery->select(\DB::raw(1)) ->from('product_barcodes') ->whereColumn('product_barcodes.product_id', 'products.id') ->where('product_barcodes.barcode', 'like', '%'.$search_term.'%'); }); } Also add the same for the EXACT search block:
    if (in_array('sub_sku', $search_fields)) { $query->orWhere('sub_sku', $search_term); } // Search in product_barcodes table (exact match) if (in_array('sku', $search_fields) || in_array('sub_sku', $search_fields)) { $query->orWhereExists(function ($subquery) use ($search_term) { $subquery->select(\DB::raw(1)) ->from('product_barcodes') ->whereColumn('product_barcodes.product_id', 'products.id') ->where('product_barcodes.barcode', $search_term); }); } Step 5: Update ProductController
    File: app/Http/Controllers/ProductController.php
    5.1 Update store() Method
    After the supplier sync code, add:
    //Add product barcodes $product_barcodes = $request->input('product_barcodes', []); if (!empty($product_barcodes)) { $this->productUtil->syncProductBarcodes($product, $product_barcodes); } 5.2 Update edit() Method
    Load the barcodes relationship:
    $product = Product::where('business_id', $business_id) ->with(['product_locations', 'suppliers', 'barcodes']) ->where('id', $id) ->firstOrFail(); 5.3 Update update() Method
    After the supplier sync code, add:
    //Sync product barcodes $product_barcodes = $request->input('product_barcodes', []); $this->productUtil->syncProductBarcodes($product, $product_barcodes); Step 6: Update Search in Other Controllers
    6.1 SellPosController
    File: app/Http/Controllers/SellPosController.php
    Find the search block in getProductSuggestion() method and update:
    //Include search if (!empty($term)) { $products->where(function ($query) use ($term) { $query->where('p.name', 'like', '%' . $term . '%'); $query->orWhere('sku', 'like', '%' . $term . '%'); $query->orWhere('sub_sku', 'like', '%' . $term . '%'); // Search in product_barcodes table $query->orWhereExists(function ($subquery) use ($term) { $subquery->select(\DB::raw(1)) ->from('product_barcodes') ->whereColumn('product_barcodes.product_id', 'p.id') ->where('product_barcodes.barcode', 'like', '%' . $term . '%'); }); }); } 6.2 PurchaseController
    File: app/Http/Controllers/PurchaseController.php
    Update the search in getProducts() method:
    ->where(function ($query) use ($term) { $query->where('products.name', 'like', '%'.$term.'%'); $query->orWhere('sku', 'like', '%'.$term.'%'); $query->orWhere('sub_sku', 'like', '%'.$term.'%'); // Search in product_barcodes table $query->orWhereExists(function ($subquery) use ($term) { $subquery->select(\DB::raw(1)) ->from('product_barcodes') ->whereColumn('product_barcodes.product_id', 'products.id') ->where('product_barcodes.barcode', 'like', '%'.$term.'%'); }); }) 6.3 UniversalSearchController
    File: app/Http/Controllers/UniversalSearchController.php
    Update the search in searchProducts() method:
    ->where(function($q) use ($query) { $q->where('products.name', 'like', "%{$query}%") ->orWhere('products.sku', 'like', "%{$query}%") ->orWhere('variations.sub_sku', 'like', "%{$query}%") ->orWhere('products.product_description', 'like', "%{$query}%") // Search in product_barcodes table ->orWhereExists(function ($subquery) use ($query) { $subquery->select(\DB::raw(1)) ->from('product_barcodes') ->whereColumn('product_barcodes.product_id', 'products.id') ->where('product_barcodes.barcode', 'like', "%{$query}%"); }); }) Step 7: Create Barcode Input Partial View
    Create File: resources/views/product/partials/product_barcodes.blade.php
    This partial contains only the container for barcode rows - the add button is placed next to the SKU field.
    <div class="col-sm-12"> <div class="form-group"> <div id="product_barcodes_container"> @if(isset($product) && $product->barcodes && $product->barcodes->count() > 0) @foreach($product->barcodes as $index => $barcode) <div class="barcode-row row" style="margin-bottom: 10px;" data-row-index="{{ $index }}"> <div class="col-md-4"> <input type="text" name="product_barcodes[{{ $index }}][barcode]" class="form-control" placeholder="@lang('product.barcode')" value="{{ $barcode->barcode }}"> </div> <div class="col-md-3"> {!! Form::select("product_barcodes[{$index}][barcode_type]", $barcode_types, $barcode->barcode_type, ['class' => 'form-control select2', 'style' => 'width: 100%;'] ) !!} </div> <div class="col-md-4"> <input type="text" name="product_barcodes[{{ $index }}][description]" class="form-control" placeholder="@lang('product.barcode_description')" value="{{ $barcode->description }}"> </div> <div class="col-md-1"> <button type="button" class="btn btn-danger btn-xs remove-barcode-row" style="padding: 2px 6px;"> <i class="fa fa-times" style="font-size: 12px;"></i> </button> </div> </div> @endforeach @endif </div> </div> </div> <script type="text/template" id="barcode_row_template"> <div class="barcode-row row" style="margin-bottom: 10px;" data-row-index="__INDEX__"> <div class="col-md-4"> <input type="text" name="product_barcodes[__INDEX__][barcode]" class="form-control" placeholder="@lang('product.barcode')"> </div> <div class="col-md-3"> <select name="product_barcodes[__INDEX__][barcode_type]" class="form-control"> @foreach($barcode_types as $key => $value) <option value="{{ $key }}">{{ $value }}</option> @endforeach </select> </div> <div class="col-md-4"> <input type="text" name="product_barcodes[__INDEX__][description]" class="form-control" placeholder="@lang('product.barcode_description')"> </div> <div class="col-md-1"> <button type="button" class="btn btn-danger btn-xs remove-barcode-row" style="padding: 2px 6px;"> <i class="fa fa-times" style="font-size: 12px;"></i> </button> </div> </div> </script> Step 8: Update Product Create/Edit Views
    8.1 Update create.blade.php
    File: resources/views/product/create.blade.php
    First, update the SKU field to add the plus button (like Unit field):
    <div class="col-sm-4"> <div class="form-group"> {!! Form::label('sku', __('product.sku') . ':') !!} @show_tooltip(__('tooltip.sku')) <div class="input-group"> {!! Form::text('sku', null, ['class' => 'form-control', 'placeholder' => __('product.sku')]); !!} <span class="input-group-btn"> <button type="button" class="btn btn-default bg-white btn-flat" id="add_barcode_row" title="@lang('product.add_barcode')"> <i class="fa fa-plus-circle text-primary fa-lg"></i> </button> </span> </div> </div> </div> Then, after the barcode_type field and the first <div class="clearfix"></div>, add:
    @include('product.partials.product_barcodes') <div class="clearfix"></div> 8.2 Update edit.blade.php
    File: resources/views/product/edit.blade.php
    Update the SKU field to add the plus button:
    <div class="col-sm-4"> <div class="form-group"> {!! Form::label('sku', __('product.sku') . ':*') !!} @show_tooltip(__('tooltip.sku')) <div class="input-group"> {!! Form::text('sku', $product->sku, ['class' => 'form-control', 'placeholder' => __('product.sku'), 'required']); !!} <span class="input-group-btn"> <button type="button" class="btn btn-default bg-white btn-flat" id="add_barcode_row" title="@lang('product.add_barcode')"> <i class="fa fa-plus-circle text-primary fa-lg"></i> </button> </span> </div> </div> </div> Then include the partial after the barcode_type field:
    @include('product.partials.product_barcodes') <div class="clearfix"></div> Step 9: Add JavaScript for Dynamic Barcode Rows
    File: public/js/product.js
    Add at the end of the file:
    // Product Additional Barcodes - Add new barcode row $(document).on("click", "#add_barcode_row", function () { var rowIndex = $("#product_barcodes_container .barcode-row").length; var template = $("#barcode_row_template").html(); template = template.replace(/__INDEX__/g, rowIndex); $("#product_barcodes_container").append(template); }); // Product Additional Barcodes - Remove barcode row $(document).on("click", ".remove-barcode-row", function () { $(this).closest(".barcode-row").remove(); // Re-index remaining rows $("#product_barcodes_container .barcode-row").each(function (index) { $(this).attr("data-row-index", index); $(this) .find("input, select") .each(function () { var name = $(this).attr("name"); if (name) { name = name.replace( /product_barcodes\[\d+\]/, "product_barcodes[" + index + "]" ); $(this).attr("name", name); } }); }); }); Step 10: Label Printing with Barcode Selection
    10.1 Update LabelsController
    File: app/Http/Controllers/LabelsController.php
    Update the show() method to load product barcodes:
    //Get products for the business $products = []; $price_groups = []; if ($purchase_id) { $products = $this->transactionUtil->getPurchaseProducts($business_id, $purchase_id); } elseif ($product_id) { $products = $this->productUtil->getDetailsFromProduct($business_id, $product_id); } // Load product barcodes for each product if (!empty($products)) { foreach ($products as $product) { $product->product_barcodes = \App\ProductBarcode::where('product_id', $product->product_id)->get(); } } Update the addProductRow() method:
    if (! empty($product_id)) { $index = $request->input('row_count'); $products = $this->productUtil->getDetailsFromProduct($business_id, $product_id, $variation_id); // Load product barcodes for each product foreach ($products as $product) { $product->product_barcodes = \App\ProductBarcode::where('product_id', $product->product_id)->get(); } $price_groups = SellingPriceGroup::where('business_id', $business_id) ->active() ->pluck('name', 'id'); return view('labels.partials.show_table_rows') ->with(compact('products', 'index', 'price_groups')); } Update the preview() method to handle selected barcode:
    foreach ($products as $value) { $details = $this->productUtil->getDetailsFromVariation($value['variation_id'], $business_id, null, false); // ... existing code for exp_date, packing_date, lot_number ... // Handle selected barcode for printing if (! empty($value['selected_barcode_id'])) { $selectedBarcode = \App\ProductBarcode::find($value['selected_barcode_id']); if ($selectedBarcode) { $details->print_barcode = $selectedBarcode->barcode; $details->print_barcode_type = $selectedBarcode->barcode_type; } } // ... rest of the code ... } 10.2 Update Label Views
    File: resources/views/labels/show.blade.php
    Add a new table header column:
    <th>@lang('lang_v1.selling_price_group')</th> <th>@lang('product.select_barcode_for_label')</th> File: resources/views/labels/partials/show_table_rows.blade.php
    Add barcode selector column after price group:
    <td> {!! Form::select('products[' . $row_index . '][price_group_id]', $price_groups, null, ['class' => 'form-control', 'placeholder' => __('lang_v1.none')]); !!} </td> <td> @php $displaySku = !empty($product->sub_sku) ? $product->sub_sku : (!empty($product->sku) ? $product->sku : ''); @endphp <select name="products[{{ $row_index }}][selected_barcode_id]" class="form-control"> <option value="">@lang('product.use_sku')@if($displaySku) ({{ $displaySku }})@endif</option> @if(isset($product->product_barcodes) && $product->product_barcodes->count() > 0) @foreach($product->product_barcodes as $barcode) <option value="{{ $barcode->id }}"> {{ $barcode->barcode }} @if($barcode->description) ({{ $barcode->description }}) @endif </option> @endforeach @endif </select> </td> File: resources/views/labels/partials/preview_2.blade.php
    Update the barcode generation section:
    {{-- Barcode --}} @php $printBarcode = $page_product->print_barcode ?? $page_product->sub_sku; $printBarcodeType = $page_product->print_barcode_type ?? $page_product->barcode_type; @endphp <img style="max-width:90% !important;height: {{$barcode_details->height*0.24}}in !important; display: block;" src="data:image/png;base64,{{DNS1D::getBarcodePNG($printBarcode, $printBarcodeType, 3,90, array(0, 0, 0), false)}}"> <span style="font-size: 10px !important"> {{$printBarcode}} </span> Step 11: Add Language Translations
    File: lang/en/product.php
    Add these translations:
    'additional_barcodes' => 'Additional Barcodes', 'add_barcode' => 'Add Barcode', 'barcode' => 'Barcode', 'barcode_description' => 'Description (optional)', 'use_sku' => 'Use SKU', 'select_barcode_for_label' => 'Barcode for Label', File: lang/en/tooltip.php
    Add this tooltip:
    'additional_barcodes' => 'Add multiple barcodes for this product. These barcodes can be used to search and identify the product in addition to the main SKU.', Summary
    After implementing all the steps above, you will have:
    Multiple barcodes per product - Each product can have unlimited additional barcodes
    Barcode type support - Each barcode can have its own type (C128, C39, EAN-13, etc.)
    Optional descriptions - Add notes like "Supplier barcode" or "Old SKU"
    Full search integration - Products are searchable by any of their barcodes in:
    POS product search
    Purchase product search
    Universal search
    Label printing selection - Choose which barcode to print on labels
    Files Modified Summary
    File
    Changes
    app/ProductBarcode.php
    New model
    app/Product.php
    Added barcodes() relationship
    app/Utils/ProductUtil.php
    Added syncProductBarcodes(), updated filterProduct()
    app/Http/Controllers/ProductController.php
    Updated store(), edit(), update()
    app/Http/Controllers/SellPosController.php
    Updated search query
    app/Http/Controllers/PurchaseController.php
    Updated search query
    app/Http/Controllers/UniversalSearchController.php
    Updated search query
    app/Http/Controllers/LabelsController.php
    Added barcode selection support
    resources/views/product/partials/product_barcodes.blade.php
    New partial view
    resources/views/product/create.blade.php
    Include barcode partial
    resources/views/product/edit.blade.php
    Include barcode partial
    resources/views/labels/show.blade.php
    Added column header
    resources/views/labels/partials/show_table_rows.blade.php
    Added barcode selector
    resources/views/labels/partials/preview_2.blade.php
    Use selected barcode
    public/js/product.js
    Added dynamic row JavaScript
    lang/en/product.php
    Added translations
    lang/en/tooltip.php
    Added tooltip
  4. Product History in POS

    Product History Button in POS Screen
    Add a Product History button to the POS header that opens a modal to search products by SKU or name and view their complete history including sales, purchases, stock movements, warranty, and expiry information.

    Download
    product-history-pos.zip
    Features
    Search by SKU or Product Name (partial match supported)
    Search by Variation SKU (sub_sku)
    5 History Tabs:
    Sales History - Invoice, date, customer, qty, price, subtotal, location
    Purchase History - Ref no, date, supplier, qty, price, subtotal, location
    Stock History - Date, transaction type, ref, location, qty in/out, balance
    Warranty Info - Invoice, date, customer, warranty details, status (Active/Expired)
    Expiry Info - Lot number, expiry date, location, stock, days to expiry, status
    Pagination - 8 records per page with navigation controls
    Bilingual Support - English and Arabic translations
    Permission-Based Access - Each tab respects user permissions
    Permissions
    The feature includes permission-based access control for each data section:
    Section
    Required Permission
    Base Access
    product.view
    Sales History
    sell.view or direct_sell.view
    Purchase History
    purchase.view
    Stock History
    product.view
    Warranty Info
    sell.view or direct_sell.view
    Expiry Info
    purchase.view
    Users will only see data for tabs they have permission to access. If a user doesn't have the required permission, the tab will show empty data.
    Files to Modify/Create
    File
    Action
    resources/views/layouts/partials/header-pos.blade.php
    Modify
    resources/views/sale_pos/partials/product_history_modal.blade.php
    Create
    resources/views/sale_pos/create.blade.php
    Modify
    resources/views/sale_pos/edit.blade.php
    Modify
    app/Http/Controllers/SellPosController.php
    Modify
    routes/web.php
    Modify
    public/js/pos.js
    Modify
    lang/en/lang_v1.php
    Modify
    lang/ar/lang_v1.php
    Modify
    Installation Steps
    Step 1: Add Button to POS Header
    Edit resources/views/layouts/partials/header-pos.blade.php
    Find the calculator button and add the product history button after it:
    <button type="button" id="product_history_btn" title="{{ __('lang_v1.product_history') }}" class="tw-shadow-[rgba(17,_17,_26,_0.1)_0px_0px_16px] tw-bg-white hover:tw-bg-white/60 tw-cursor-pointer tw-border-2 tw-flex tw-items-center tw-justify-center tw-rounded-md md:tw-w-8 tw-w-auto tw-h-8 tw-text-gray-600 pull-right" data-toggle="modal" data-target="#product_history_modal"> <strong class="!tw-m-3"> <i class="fas fa-history fa-lg tw-text-[#9333EA] !tw-text-sm" aria-hidden="true"></i> <span class="tw-inline md:tw-hidden">{{ __('lang_v1.product_history') }}</span> </strong> </button> Step 2: Create Modal View
    Create new file resources/views/sale_pos/partials/product_history_modal.blade.php
    Copy the content from: product_history_modal.blade.php
    Step 3: Include Modal in POS Pages
    Edit resources/views/sale_pos/create.blade.php
    Find @include('sale_pos.partials.weighing_scale_modal') and add after it:
    @include('sale_pos.partials.product_history_modal') Edit resources/views/sale_pos/edit.blade.php
    Add the same include after @include('sale_pos.partials.weighing_scale_modal'):
    @include('sale_pos.partials.product_history_modal') Step 4: Add Controller Method
    Edit app/Http/Controllers/SellPosController.php
    Add the getProductHistory method before the closing brace of the class.
    Copy the content from: SellPosController_getProductHistory.php
    Step 5: Add Route
    Edit routes/web.php
    Find the line:
    Route::get('/sells/pos/get-featured-products/{location_id}', [SellPosController::class, 'getFeaturedProducts']); Add after it:
    Route::get('/sells/pos/get-product-history', [SellPosController::class, 'getProductHistory']); Step 6: Add JavaScript
    Edit public/js/pos.js
    Add the JavaScript code at the end of the file.
    Copy the content from: pos_product_history.js
    Step 7: Add Translations
    Edit lang/en/lang_v1.php and lang/ar/lang_v1.php
    Add the translations before the closing ];
    Copy the content from: lang_v1_translations.php
    Usage
    Go to POS screen
    Click the purple history icon button in the header
    Enter product SKU or name in the search box
    Press Enter or click Search
    View history across all tabs (Sales, Purchases, Stock, Warranty, Expiry)
    Use pagination controls to navigate through records
    Screenshots
    Button Location
    The button appears in the POS header with a purple history icon.
    Modal View
    Product info displayed at top (image, name, SKU, current stock)
    5 tabs for different history types
    Pagination at bottom of each table
    Customization
    Change Records Per Page
    Edit SellPosController.php line with $per_page = 8; to change the number of records per page.
    Change Button Color
    Edit the button class in header-pos.blade.php:
    Current: tw-text-[#9333EA] (purple)
    Change to any Tailwind color class
    Troubleshooting
    Modal Not Opening
    Check if the modal include is added to both create.blade.php and edit.blade.php
    Clear browser cache and refresh
    No Data Showing
    Verify the route is added correctly
    Check browser console for JavaScript errors
    Verify controller method is added
    Translations Not Working
    Clear Laravel cache: php artisan cache:clear
    Check translation keys match exactly
  5. Lot Stock Validation in POS

    Lot/Expiry Stock Validation in POS Implementation Guide
    This guide shows how to add dynamic lot/expiry stock validation to the POS screen in Ultimate POS. When products with lot numbers or expiry dates are added to POS, selecting a specific lot will now dynamically update the available stock display and validate quantity against the selected lot's stock.

    Download
    lot-stock-validation-pos.zip
    The Problem
    In Stock Transfer, when you select a lot for a product:
    The available stock updates dynamically to show that lot's quantity
    Validation prevents entering more than available in that lot
    Visual feedback shows "Current Stock: X" that updates on lot selection
    However, in POS this feature was missing - the stock display was static and didn't update when selecting different lots.
    The Solution
    This implementation adds the same lot stock validation behavior to POS that already exists in Stock Transfer:
    Dynamic stock display that updates when lot is selected
    Real-time validation against selected lot's available quantity
    Stock display updates when changing sub-units
    Visual feedback with "Current Stock: X" label
    Implementation Tree Structure
    your-laravel-project/ ├── public/ │ └── js/ │ └── pos.js # 📝
    MODIFY
    └── resources/
    └── views/
    └── sale_pos/
    └── product_row.blade.php # 📝 MODIFY

    Files Overview
    File
    Action
    Description
    product_row.blade.php
    📝 Modify
    Add dynamic stock display span with qty_available_text
    pos.js
    📝 Modify
    Update lot change handler and sub_unit change handler
    Features
    ✅ Dynamic stock display updates when selecting lots
    ✅ Real-time quantity validation per lot
    ✅ Sub-unit changes update stock display correctly
    ✅ Shows "Current Stock: X" label like Stock Transfer
    ✅ Error message when quantity exceeds lot stock
    ✅ Works with both POS and Direct Sell screens
    Prerequisites
    Ultimate POS with lot number/expiry date feature enabled
    Products with lot numbers configured
    Existing POS functionality working
    Implementation Steps
    Step 1: Update POS Product Row Template
    Update your resources/views/sale_pos/product_row.blade.php file to add the dynamic stock display.
    Find this section (around line 117):
    <small class="text-muted p-1"> @if($product->enable_stock) {{ @num_format($product->qty_available) }} {{$product->unit}} @lang('lang_v1.in_stock') @else -- @endif </small> Replace with:
    <small class="text-muted p-1" style="white-space: nowrap;"> @if($product->enable_stock) @lang('report.current_stock'): <span class="qty_available_text">{{ @num_format($product->qty_available) }}</span> {{$product->unit}} @else -- @endif </small> Key changes:
    Added qty_available_text span class for dynamic updates via JavaScript
    Changed label to use @lang('report.current_stock') for consistency with Stock Transfer
    Added white-space: nowrap to prevent text wrapping
    Step 2: Update Lot Number Change Handler in pos.js
    Update the lot number change handler in public/js/pos.js to update the stock display.
    Find this section (around line 416):
    //Change max quantity rule if lot number changes $('table#pos_table tbody').on('change', 'select.lot_number', function () { var qty_element = $(this) .closest('tr') .find('input.pos_quantity'); var tr = $(this).closest('tr'); var multiplier = 1; // ... rest of the code Replace the entire lot_number change handler with:
    //Change max quantity rule if lot number changes $('table#pos_table tbody').on('change', 'select.lot_number', function () { var qty_element = $(this) .closest('tr') .find('input.pos_quantity'); var tr = $(this).closest('tr'); var qty_available_el = tr.find('.qty_available_text'); var multiplier = 1; var unit_name = ''; var sub_unit_length = tr.find('select.sub_unit').length; if (sub_unit_length > 0) { var select = tr.find('select.sub_unit'); multiplier = parseFloat(select.find(':selected').data('multiplier')); unit_name = select.find(':selected').data('unit_name'); } var allow_overselling = qty_element.data('allow-overselling'); if ($(this).val() && !allow_overselling) { var lot_qty = $('option:selected', $(this)).data('qty_available'); var max_err_msg = $('option:selected', $(this)).data('msg-max'); if (sub_unit_length > 0) { lot_qty = lot_qty / multiplier; var lot_qty_formated = __number_f(lot_qty, false); max_err_msg = __translate('lot_max_qty_error', { max_val: lot_qty_formated, unit_name: unit_name, }); } qty_element.attr('data-rule-max-value', lot_qty); qty_element.attr('data-msg-max-value', max_err_msg); qty_element.rules('add', { 'max-value': lot_qty, messages: { 'max-value': max_err_msg, }, }); // Update the stock display text with lot quantity if (qty_available_el.length) { qty_available_el.text(__currency_trans_from_en(lot_qty, false)); } } else { var default_qty = qty_element.data('qty_available'); var default_err_msg = qty_element.data('msg_max_default'); if (sub_unit_length > 0) { default_qty = default_qty / multiplier; var lot_qty_formated = __number_f(default_qty, false); default_err_msg = __translate('pos_max_qty_error', { max_val: lot_qty_formated, unit_name: unit_name, }); } qty_element.attr('data-rule-max-value', default_qty); qty_element.attr('data-msg-max-value', default_err_msg); qty_element.rules('add', { 'max-value': default_qty, messages: { 'max-value': default_err_msg, }, }); // Reset the stock display text to default quantity if (qty_available_el.length) { qty_available_el.text(__currency_trans_from_en(default_qty, false)); } } qty_element.trigger('change'); }); Key additions:
    Added qty_available_el variable to find the stock display span
    Added code to update stock display when lot is selected (lines with qty_available_el.text(...))
    Added code to reset stock display when lot selection is cleared
    Step 3: Update Sub Unit Change Handler in pos.js
    Update the sub_unit change handler to also update the stock display.
    Find the sub_unit change handler section (around line 1406):
    if (base_max_avlbl) { var max_avlbl = parseFloat(base_max_avlbl) / multiplier; var formated_max_avlbl = __number_f(max_avlbl); var unit_name = selected_option.data('unit_name'); var max_err_msg = __translate(error_msg_line, { max_val: formated_max_avlbl, unit_name: unit_name, }); qty_element.attr('data-rule-max-value', max_avlbl); qty_element.attr('data-msg-max-value', max_err_msg); qty_element.rules('add', { 'max-value': max_avlbl, messages: { 'max-value': max_err_msg, }, }); qty_element.trigger('change'); } adjustComboQty(tr); Replace with:
    if (base_max_avlbl) { var max_avlbl = parseFloat(base_max_avlbl) / multiplier; var formated_max_avlbl = __number_f(max_avlbl); var unit_name = selected_option.data('unit_name'); var max_err_msg = __translate(error_msg_line, { max_val: formated_max_avlbl, unit_name: unit_name, }); qty_element.attr('data-rule-max-value', max_avlbl); qty_element.attr('data-msg-max-value', max_err_msg); qty_element.rules('add', { 'max-value': max_avlbl, messages: { 'max-value': max_err_msg, }, }); // Update the stock display text with available quantity var qty_available_el = tr.find('.qty_available_text'); if (qty_available_el.length) { qty_available_el.text(__currency_trans_from_en(max_avlbl, false)); } qty_element.trigger('change'); } adjustComboQty(tr); Key addition:
    Added code to update stock display when sub_unit changes
    How It Works
    Product Added to POS: Shows total available stock for the product
    Lot Selected: Stock display updates to show only that lot's available quantity
    Quantity Validation: If user enters more than lot stock, validation error appears
    Lot Deselected: Stock display resets to total product stock
    Sub-unit Changed: Stock display updates with converted quantity
    Verification Steps
    After implementation, verify that:
    Add a product with lot/expiry to POS
    Check stock display shows "Current Stock: X"
    Select a lot from the dropdown
    Stock display updates to show that lot's quantity
    Enter quantity exceeding lot stock - validation error appears
    Clear lot selection - stock display resets to total
    Change sub-unit (if available) - stock display converts correctly
    Comparison with Stock Transfer
    Feature
    Stock Transfer
    POS (Before)
    POS (After)
    Dynamic stock display



    Lot quantity validation



    Visual feedback on lot change



    Sub-unit stock conversion



    "Current Stock" label



    Troubleshooting
    Issue: Stock display not updating
    Solution:
    Check that qty_available_text span class exists in the blade template
    Verify JavaScript changes are in the correct location
    Clear browser cache and reload
    Issue: Validation not working
    Solution:
    Ensure allow_overselling is not enabled in POS settings
    Check that lot options have data-qty_available attribute
    Issue: Stock shows NaN or undefined
    Solution:
    Verify lot numbers have proper qty_available data attributes
    Check that __currency_trans_from_en function exists in common.js
    Download Implementation Files
    The modified files are available for download:
    product_row.blade-f7a5a8e53d01beeb572cf7773ac3f304.php
    pos_sub_unit_handler-02dc0360e26b3578e92f3838dc122857.js
    pos_lot_number_handler-8f6bd0948b5887a4ad0975f0f1f2fe10.js
    Conclusion
    This implementation brings the POS lot/expiry stock validation to feature parity with Stock Transfer, providing users with real-time feedback on available stock per lot and preventing over-selling from specific lots.
  6. Adding Multiple Suppliers to Products

    Adding Multiple Suppliers to Products - Complete Implementation Guide
    Overview
    This guide will walk you through the complete process of adding multiple suppliers functionality to your Ultimate POS products. This feature allows you to:
    Assign multiple suppliers to products during creation and editing
    Filter products by supplier in the products list
    Bulk update suppliers for multiple products
    View all suppliers information in product details
    Use many-to-many relationship between products and suppliers
      
    Download Complete Files
    Download All Updated Files
    adding-supplier-to-products.zip
    Step 1: Database Structure
    Migration and Direct SQL
    Ultimate POS already has a contacts table that stores suppliers. For multiple suppliers per product, we need to create a pivot table (many-to-many relationship).
    Create Migration:
    php artisan make:migration create_product_supplier_table Migration Content:
    <?php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up() { Schema::create('product_supplier', function (Blueprint $table) { $table->id(); $table->unsignedInteger('product_id'); $table->unsignedInteger('supplier_id'); $table->timestamps(); // Foreign keys $table->foreign('product_id')->references('id')->on('products')->onDelete('cascade'); $table->foreign('supplier_id')->references('id')->on('contacts')->onDelete('cascade'); // Prevent duplicate entries $table->unique(['product_id', 'supplier_id']); // Indexes for better performance $table->index('product_id'); $table->index('supplier_id'); }); } public function down() { Schema::dropIfExists('product_supplier'); } }; Direct SQL (Alternative):
    -- Create pivot table for product-supplier relationship CREATE TABLE `product_supplier` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `product_id` INT UNSIGNED NOT NULL, `supplier_id` INT UNSIGNED NOT NULL, `created_at` TIMESTAMP NULL, `updated_at` TIMESTAMP NULL, -- Foreign keys CONSTRAINT `fk_product_supplier_product_id` FOREIGN KEY (`product_id`) REFERENCES `products`(`id`) ON DELETE CASCADE, CONSTRAINT `fk_product_supplier_supplier_id` FOREIGN KEY (`supplier_id`) REFERENCES `contacts`(`id`) ON DELETE CASCADE, -- Unique constraint to prevent duplicates UNIQUE KEY `unique_product_supplier` (`product_id`, `supplier_id`), -- Indexes for performance INDEX `idx_product_supplier_product_id` (`product_id`), INDEX `idx_product_supplier_supplier_id` (`supplier_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; Run Migration:
    php artisan migrate Note: This creates a many-to-many relationship allowing each product to have multiple suppliers and each supplier to supply multiple products.
    Step 2: Model Updates
    Update Product Model
    File: app/Product.php
    Add relationship method for many-to-many relationship:
    /** * Get the suppliers for the product (many-to-many). */ public function suppliers() { return $this->belongsToMany( \App\Contact::class, 'product_supplier', 'product_id', 'supplier_id' )->withTimestamps(); } File: app/Contact.php
    Add relationship method for many-to-many relationship:
    /** * Get products supplied by this supplier (many-to-many) */ public function products() { return $this->belongsToMany( \App\Product::class, 'product_supplier', 'supplier_id', 'product_id' )->withTimestamps(); } /** * Get products supplied by this supplier with stock enabled */ public function stockProducts() { return $this->belongsToMany( \App\Product::class, 'product_supplier', 'supplier_id', 'product_id' )->where('products.enable_stock', 1) ->withTimestamps(); } Important Notes:
    The belongsToMany relationship requires the pivot table name (product_supplier)
    Use sync() method to attach/detach suppliers in controllers
    The withTimestamps() method ensures the pivot table timestamps are maintained
    Step 3: Controller Updates
    ProductController Changes
    File: app/Http/Controllers/ProductController.php
    3.1 Update index() method
    Add suppliers to eager loading (around line 77):
    $query = Product::with(['media', 'suppliers']) Add supplier filter to query (around line 162):
    $supplier_id = request()->get('supplier_id', null); if (!empty($supplier_id)) { $products->whereHas('suppliers', function ($query) use ($supplier_id) { $query->where('contacts.id', $supplier_id); }); } Add suppliers column to DataTables (after selling_price column):
    ->addColumn('suppliers', function ($row) { $suppliers = $row->suppliers->pluck('name')->toArray(); return implode(', ', $suppliers); }) Add suppliers dropdown for view (around line 334):
    $suppliers = \App\Contact::suppliersDropdown($business_id); Update compact statement to include suppliers:
    return view('product.index') ->with(compact( 'rack_enabled', 'categories', 'brands', 'units', 'taxes', 'business_locations', 'show_manufacturing_data', 'pos_module_data', 'is_woocommerce', 'is_admin', 'suppliers' )); 3.2 Update create() method
    Add suppliers dropdown (around line 150):
    $suppliers = \App\Contact::suppliersDropdown($business_id); Update compact statement:
    return view('product.create') ->with(compact('categories', 'brands', 'units', 'taxes', 'barcode_types', 'default_profit_percent', 'tax_attributes', 'barcode_default', 'business_locations', 'duplicate_product', 'sub_categories', 'rack_details', 'selling_price_group_count', 'module_form_parts', 'product_types', 'common_settings', 'warranties', 'pos_module_data', 'suppliers')); 3.3 Update store() method
    Add supplier sync after product creation (after product locations sync, around line 645):
    // Sync product suppliers $supplier_ids = $request->input('supplier_ids', []); if (!empty($supplier_ids)) { $product->suppliers()->sync($supplier_ids); } 3.4 Update edit() method
    Add suppliers dropdown (around line 350):
    $suppliers = \App\Contact::suppliersDropdown($business_id); Update compact statement:
    return view('product.edit') ->with(compact('categories', 'brands', 'units', 'sub_units', 'taxes', 'tax_attributes', 'barcode_types', 'product', 'sub_categories', 'default_profit_percent', 'business_locations', 'rack_details', 'selling_price_group_count', 'module_form_parts', 'product_types', 'common_settings', 'warranties', 'pos_module_data', 'alert_quantity', 'suppliers')); 3.5 Update update() method
    Add supplier sync after product update (after product locations sync, around line 820):
    // Sync product suppliers $supplier_ids = $request->input('supplier_ids', []); $product->suppliers()->sync($supplier_ids); 3.6 Update view() method
    Add suppliers to with array (around line 920):
    $product = Product::where('business_id', $business_id) ->with(['brand', 'unit', 'category', 'sub_category', 'product_tax', 'variations', 'variations.product_variation', 'variations.group_prices', 'variations.media', 'product_locations', 'warranty', 'media', 'suppliers']) ->findOrFail($id); 3.7 Add bulk supplier update method
    public function bulkUpdateSupplier(Request $request) { if (!auth()->user()->can('product.update')) { abort(403, 'Unauthorized action.'); } try { $selected_products = $request->input('selected_products'); $supplier_ids = $request->input('supplier_ids', []); $business_id = $request->session()->get('user.business_id'); if (empty($selected_products)) { $output = [ 'success' => 0, 'msg' => __('lang_v1.no_products_selected') ]; return $output; } $product_ids = explode(',', $selected_products); DB::beginTransaction(); // Get products and sync suppliers for each $products = Product::where('business_id', $business_id) ->whereIn('id', $product_ids) ->get(); foreach ($products as $product) { $product->suppliers()->sync($supplier_ids); } DB::commit(); $output = [ 'success' => 1, 'msg' => __('lang_v1.supplier_updated_success') ]; } catch (\Exception $e) { DB::rollBack(); \Log::emergency("File:" . $e->getFile() . "Line:" . $e->getLine() . "Message:" . $e->getMessage()); $output = [ 'success' => 0, 'msg' => __('messages.something_went_wrong') ]; } return $output; } 3.8 Update other methods
    quickAdd() method (around line 1500):
    Add suppliers dropdown:
    $suppliers = \App\Contact::suppliersDropdown($business_id, false); Update the compact statement to include suppliers:
    return view('product.partials.quick_add_product') ->with(compact('categories', 'brands', 'units', 'taxes', 'barcode_types', 'default_profit_percent', 'tax_attributes', 'product_name', 'locations', 'product_for', 'enable_expiry', 'enable_lot', 'module_form_parts', 'business_locations', 'common_settings', 'warranties', 'suppliers')); saveQuickProduct() method:
    The supplier_ids handling is done via sync relationship, NOT through form_fields. Add this code after syncing product_locations (around line 1610):
    //Add product suppliers $supplier_ids = $request->input('supplier_ids'); if (!empty($supplier_ids)) { $product->suppliers()->sync($supplier_ids); } bulkEdit() and bulkUpdate() methods:
    // Add supplier handling as shown in previous implementation guide Step 4: Routes
    File: routes/web.php
    Add the bulk supplier update route:
    Route::post('/products/bulk-update-supplier', [ProductController::class, 'bulkUpdateSupplier']) ->name('products.bulk-update-supplier'); Step 5: View Updates
    5.1 Product Index Page
    File: resources/views/product/index.blade.php
    Add supplier filter (after brand filter around line 90):
    <div class="col-md-3"> <div class="form-group"> {!! Form::label('supplier_id', __('contact.supplier') . ':') !!} {!! Form::select('supplier_id', $suppliers ?? [], null, [ 'class' => 'form-control select2', 'style' => 'width:100%', 'id' => 'product_list_filter_supplier_id', 'placeholder' => __('lang_v1.all'), ]) !!} </div> </div> Update JavaScript DataTable configuration:
    Add to ajax data function:
    d.supplier_id = $('#product_list_filter_supplier_id').val(); Add to columns array (after brand column):
    { data: 'suppliers', name: 'suppliers', orderable: false, searchable: false }, Update change event listener:
    $(document).on('change', '#product_list_filter_type, #product_list_filter_category_id, #product_list_filter_brand_id, #product_list_filter_supplier_id, #product_list_filter_unit_id, #product_list_filter_tax_id, #location_id, #active_state, #repair_model_id', function() { // existing code }); Add bulk supplier modal:
    <!-- Bulk Supplier Update Modal --> <div class="modal fade" id="bulk_supplier_modal" tabindex="-1" role="dialog"> <div class="modal-dialog" role="document"> <div class="modal-content"> <div class="modal-header"> <button type="button" class="close" data-dismiss="modal"> <span>&times;</span> </button> <h4 class="modal-title">@lang('lang_v1.bulk_update_supplier')</h4> </div> <form id="bulk_supplier_form" method="POST" action="{{ route('products.bulk-update-supplier') }}"> @csrf <div class="modal-body"> <div class="form-group"> <label for="bulk_supplier_id">@lang('contact.supplier'):</label> <select name="supplier_id" id="bulk_supplier_id" class="form-control select2" style="width: 100%;"> <option value="">@lang('lang_v1.none')</option> @if(isset($suppliers)) @foreach($suppliers as $id => $name) @if($id != '') <option value="{{ $id }}">{{ $name }}</option> @endif @endforeach @endif </select> </div> <input type="hidden" name="selected_products" id="bulk_selected_products"> </div> <div class="modal-footer"> <button type="button" class="btn btn-default" data-dismiss="modal">@lang('messages.close')</button> <button type="submit" class="btn btn-primary">@lang('messages.update')</button> </div> </form> </div> </div> </div> Add bulk supplier JavaScript:
    // Bulk supplier update functionality $(document).on('click', '.bulk-update-supplier', function(e) { e.preventDefault(); var selected_rows = getSelectedRows(); if (selected_rows.length > 0) { $('#bulk_selected_products').val(selected_rows); $('#bulk_supplier_modal').modal('show'); $('#bulk_supplier_id').select2({ dropdownParent: $('#bulk_supplier_modal') }); } else { swal('@lang("lang_v1.no_row_selected")'); } }); $(document).on('submit', '#bulk_supplier_form', function(e) { e.preventDefault(); var form = $(this); var data = form.serialize(); $.ajax({ method: 'POST', url: form.attr('action'), dataType: 'json', data: data, beforeSend: function(xhr) { form.find('button[type="submit"]').attr('disabled', true); }, success: function(result) { if (result.success == 1) { $('#bulk_supplier_modal').modal('hide'); toastr.success(result.msg); product_table.ajax.reload(); } else { toastr.error(result.msg); } form.find('button[type="submit"]').attr('disabled', false); }, error: function(xhr) { toastr.error('@lang("messages.something_went_wrong")'); form.find('button[type="submit"]').attr('disabled', false); } }); }); 5.2 Product Table
    File: resources/views/product/partials/product_list.blade.php
    Add supplier column header (after brand):
    <th>@lang('lang_v1.suppliers')</th> Add bulk supplier button (in tfoot after existing buttons):
    &nbsp; <button type="button" class="tw-dw-btn tw-dw-btn-xs tw-dw-btn-outline tw-dw-btn-info bulk-update-supplier"> <i class="fa fa-users"></i> @lang('lang_v1.bulk_update_supplier') </button> 5.3 Product Forms
    Create Form (resources/views/product/create.blade.php):
    Add after brand field (around line 87):
    <div class="col-sm-4"> <div class="form-group"> {!! Form::label('supplier_ids', __('lang_v1.suppliers') . ':') !!} <div class="input-group"> {!! Form::select('supplier_ids[]', $suppliers, !empty($duplicate_product) ? $duplicate_product->suppliers->pluck('id')->toArray() : null, ['class' => 'form-control select2', 'multiple', 'id' => 'supplier_ids']); !!} <span class="input-group-btn"> <button type="button" @if(!auth()->user()->can('supplier.create')) disabled @endif class="btn btn-default bg-white btn-flat quick_add_supplier_btn" title="@lang('contact.add_supplier')"><i class="fa fa-plus-circle text-primary fa-lg"></i></button> </span> </div> </div> </div> Edit Form (resources/views/product/edit.blade.php):
    Add after brand field (around line 98):
    <div class="col-sm-4"> <div class="form-group"> {!! Form::label('supplier_ids', __('lang_v1.suppliers') . ':') !!} <div class="input-group"> {!! Form::select('supplier_ids[]', $suppliers, $product->suppliers->pluck('id')->toArray(), ['class' => 'form-control select2', 'multiple', 'id' => 'supplier_ids']); !!} <span class="input-group-btn"> <button type="button" @if(!auth()->user()->can('supplier.create')) disabled @endif class="btn btn-default bg-white btn-flat quick_add_supplier_btn" title="@lang('contact.add_supplier')"><i class="fa fa-plus-circle text-primary fa-lg"></i></button> </span> </div> </div> </div> Note: The edit form pre-populates with the product's current suppliers using $product->suppliers->pluck('id')->toArray()
    5.4 Quick Add Supplier Modal (Simplified Form)
    This section implements a simplified quick add supplier modal similar to the purchase page, with only essential fields: Supplier Name (or Business Name) and Mobile Number, plus Individual/Business toggle.
    File: resources/views/product/partials/quick_add_supplier_modal.blade.php (Create this new file)
    <!-- Quick Add Supplier Modal for Product Pages --> <div class="modal fade" id="quick_add_supplier_modal" tabindex="-1" role="dialog" aria-labelledby="quickAddSupplierModalLabel"> <div class="modal-dialog" role="document"> <div class="modal-content"> {!! Form::open(['url' => action([\App\Http\Controllers\ContactController::class, 'store']), 'method' => 'post', 'id' => 'quick_add_supplier_form']) !!} <div class="modal-header"> <button type="button" class="close" data-dismiss="modal" aria-label="Close"> <span aria-hidden="true">&times;</span> </button> <h4 class="modal-title" id="quickAddSupplierModalLabel">@lang('contact.add_supplier')</h4> </div> <div class="modal-body"> {!! Form::hidden('type', 'supplier') !!} <div class="row"> <!-- Individual / Business Toggle --> <div class="col-md-12" style="margin-bottom: 15px;"> <label class="radio-inline"> <input type="radio" name="supplier_type_radio" class="supplier_type_radio" value="individual" checked> @lang('lang_v1.individual') </label> &nbsp;&nbsp;&nbsp; <label class="radio-inline"> <input type="radio" name="supplier_type_radio" class="supplier_type_radio" value="business"> @lang('business.business') </label> </div> <div class="clearfix"></div> <!-- Individual Fields --> <div class="col-md-6 supplier_individual_fields"> <div class="form-group"> {!! Form::label('first_name', __('business.first_name') . ':*') !!} <div class="input-group"> <span class="input-group-addon"> <i class="fa fa-user"></i> </span> {!! Form::text('first_name', null, ['class' => 'form-control', 'placeholder' => __('business.first_name'), 'id' => 'supplier_first_name']); !!} </div> </div> </div> <div class="col-md-6 supplier_individual_fields"> <div class="form-group"> {!! Form::label('last_name', __('business.last_name') . ':') !!} <div class="input-group"> <span class="input-group-addon"> <i class="fa fa-user"></i> </span> {!! Form::text('last_name', null, ['class' => 'form-control', 'placeholder' => __('business.last_name'), 'id' => 'supplier_last_name']); !!} </div> </div> </div> <!-- Business Fields (Hidden by default) --> <div class="col-md-12 supplier_business_fields" style="display: none;"> <div class="form-group"> {!! Form::label('supplier_business_name', __('business.business_name') . ':*') !!} <div class="input-group"> <span class="input-group-addon"> <i class="fa fa-briefcase"></i> </span> {!! Form::text('supplier_business_name', null, ['class' => 'form-control', 'placeholder' => __('business.business_name'), 'id' => 'supplier_business_name_input']); !!} </div> </div> </div> <!-- Mobile Number (Always visible) --> <div class="col-md-6"> <div class="form-group"> {!! Form::label('mobile', __('contact.mobile') . ':*') !!} <div class="input-group"> <span class="input-group-addon"> <i class="fa fa-mobile"></i> </span> {!! Form::text('mobile', null, ['class' => 'form-control', 'required', 'placeholder' => __('contact.mobile'), 'id' => 'supplier_mobile']); !!} </div> </div> </div> <!-- Email (Optional) --> <div class="col-md-6"> <div class="form-group"> {!! Form::label('email', __('business.email') . ':') !!} <div class="input-group"> <span class="input-group-addon"> <i class="fa fa-envelope"></i> </span> {!! Form::email('email', null, ['class' => 'form-control', 'placeholder' => __('business.email'), 'id' => 'supplier_email']); !!} </div> </div> </div> </div> </div> <div class="modal-footer"> <button type="button" class="tw-dw-btn tw-dw-btn-neutral tw-text-white" data-dismiss="modal">@lang('messages.close')</button> <button type="submit" class="tw-dw-btn tw-dw-btn-primary tw-text-white">@lang('messages.save')</button> </div> {!! Form::close() !!} </div> </div> </div> Include the modal in product create/edit pages:
    Add at the bottom of resources/views/product/create.blade.php and resources/views/product/edit.blade.php:
    @include('product.partials.quick_add_supplier_modal') 5.5 JavaScript for Quick Add Supplier
    Add the following JavaScript to handle the quick add supplier modal. You can add this to public/js/product.js or include it in the product pages.
    Add to public/js/product.js or inline in product views:
    // Quick Add Supplier Modal - Toggle Individual/Business fields $(document).on('change', '.supplier_type_radio', function() { var selectedType = $(this).val(); if (selectedType === 'individual') { $('.supplier_individual_fields').show(); $('.supplier_business_fields').hide(); // Update required attributes $('#supplier_first_name').prop('required', true); $('#supplier_business_name_input').prop('required', false); } else if (selectedType === 'business') { $('.supplier_individual_fields').hide(); $('.supplier_business_fields').show(); // Update required attributes $('#supplier_first_name').prop('required', false); $('#supplier_business_name_input').prop('required', true); } }); // Open Quick Add Supplier Modal $(document).on('click', '.quick_add_supplier_btn', function(e) { e.preventDefault(); // Reset form $('#quick_add_supplier_form')[0].reset(); // Reset to individual by default $('input[name="supplier_type_radio"][value="individual"]').prop('checked', true).trigger('change'); // Show modal $('#quick_add_supplier_modal').modal('show'); }); // Handle Quick Add Supplier Form Submission $(document).on('submit', '#quick_add_supplier_form', function(e) { e.preventDefault(); var form = $(this); var submitBtn = form.find('button[type="submit"]'); var formData = form.serialize(); $.ajax({ method: 'POST', url: form.attr('action'), data: formData, dataType: 'json', beforeSend: function() { submitBtn.prop('disabled', true); submitBtn.html('<i class="fa fa-spinner fa-spin"></i> ' + LANG.please_wait); }, success: function(result) { if (result.success === true) { // Close modal $('#quick_add_supplier_modal').modal('hide'); // Show success message toastr.success(result.msg); // Add the new supplier to the supplier_ids select2 dropdown var newOption = new Option(result.data.name, result.data.id, true, true); $('#supplier_ids').append(newOption).trigger('change'); // Reset form form[0].reset(); } else { toastr.error(result.msg); } }, error: function(xhr) { var errorMsg = xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : LANG.something_went_wrong; toastr.error(errorMsg); }, complete: function() { submitBtn.prop('disabled', false); submitBtn.html(LANG.save); } }); }); // Initialize modal on page load $(document).ready(function() { // Ensure individual fields are shown by default $('.supplier_individual_fields').show(); $('.supplier_business_fields').hide(); }); 5.6 Controller Update for AJAX Response
    Update the ContactController@store method to return JSON response when called via AJAX:
    File: app/Http/Controllers/ContactController.php
    Find the store() method and update the success response to handle AJAX requests:
    // At the end of the store() method, before the redirect, add: if ($request->ajax()) { // Get the display name for the supplier $displayName = $contact->supplier_business_name ? $contact->supplier_business_name : trim($contact->prefix . ' ' . $contact->first_name . ' ' . $contact->middle_name . ' ' . $contact->last_name); return response()->json([ 'success' => true, 'msg' => __('contact.added_success'), 'data' => [ 'id' => $contact->id, 'name' => $displayName, 'mobile' => $contact->mobile, 'type' => $contact->type ] ]); } Full example of modified store() method ending:
    // ... existing store logic ... DB::commit(); $output = [ 'success' => true, 'data' => $contact, 'msg' => __('contact.added_success') ]; // Handle AJAX requests (for quick add modals) if ($request->ajax()) { $displayName = $contact->supplier_business_name ? $contact->supplier_business_name : trim($contact->prefix . ' ' . $contact->first_name . ' ' . $contact->middle_name . ' ' . $contact->last_name); return response()->json([ 'success' => true, 'msg' => __('contact.added_success'), 'data' => [ 'id' => $contact->id, 'name' => $displayName, 'mobile' => $contact->mobile, 'type' => $contact->type ] ]); } // Existing redirect logic for non-AJAX requests return redirect('contacts')->with('status', $output); 5.7 Product View Modal
    File: resources/views/product/view-modal.blade.php
    Add supplier information after brand:
    <b>@lang('contact.supplier'): </b> @if($product->suppliers->isNotEmpty()) {{ $product->suppliers->pluck('name')->implode(', ') }} @else -- @endif <br> Note: Since a product can have multiple suppliers, we display them as a comma-separated list.
    5.8 Quick Add Product Form
    File: resources/views/product/partials/quick_add_product.blade.php
    Add after brand field (around line 57):
    <div class="col-sm-4"> <div class="form-group"> {!! Form::label('supplier_ids', __('contact.supplier') . ':') !!} <div class="input-group"> {!! Form::select('supplier_ids[]', $suppliers, null, ['class' => 'form-control select2', 'multiple', 'id' => 'quick_product_supplier_ids']); !!} <span class="input-group-btn"> <button type="button" @if(!auth()->user()->can('supplier.create')) disabled @endif class="btn btn-default bg-white btn-flat quick_add_supplier_btn" title="@lang('contact.add_supplier')"><i class="fa fa-plus-circle text-primary fa-lg"></i></button> </span> </div> </div> </div> Note: The quick add supplier modal will also work in this form since the JavaScript is event-delegated.
    Important Notes:
    Use supplier_ids[] (array) for multiple supplier selection
    Add 'multiple' => true to enable multi-select
    The quick add supplier button uses the same simplified modal
    The field name must match what saveQuickProduct() expects
    Step 6: Language Files
    File: resources/lang/en/lang_v1.php
    Add these translations:
    'bulk_update_supplier' => 'Bulk Update Supplier', 'supplier_updated_success' => 'Supplier updated successfully', 'no_products_selected' => 'No products selected', Step 7: Testing
    7.1 Basic Functionality
    Create Product: Test creating a product with multiple supplier selection
    Edit Product: Test editing existing products and changing/adding/removing suppliers
    View Product: Verify all suppliers appear in product details modal (comma-separated)
    Filter Products: Test filtering products by supplier in the index page
    7.2 Bulk Operations
    Select Multiple Products: Use checkboxes to select multiple products
    Bulk Update Supplier: Click bulk supplier button and assign multiple suppliers
    Verify Changes: Check that all selected products have the new suppliers (replaces existing)
    7.3 Edge Cases
    No Supplier Selected: Test with empty/null supplier values (should work fine)
    Invalid Supplier: Test with non-existent supplier IDs
    Permission Testing: Test with different user permissions
    Large Datasets: Test with many products and many suppliers selected
    Duplicate Prevention: Try to assign the same supplier twice (should be prevented by unique constraint)
    Step 8: Optional Enhancements
    8.1 Supplier Statistics
    Add supplier-based reports showing:
    Products per supplier
    Stock levels by supplier
    Purchase history by supplier
    8.2 Advanced Filtering
    Add more complex filtering options:
    Products without suppliers
    Supplier-based stock alerts
    Multi-supplier selection
    8.3 Import/Export
    Update product import/export to include supplier information:
    CSV import with supplier names
    Excel export with supplier details
    Conclusion
    You have successfully implemented multiple suppliers functionality for products in Ultimate POS. This feature provides:
    ✅ Multiple supplier assignment during product creation/editing
    ✅ Many-to-many relationship between products and suppliers
    ✅ Supplier filtering in product listings
    ✅ Bulk supplier updates for multiple products (with multiple supplier selection)
    ✅ All suppliers information displayed in product details
    ✅ Integration with existing supplier management
    ✅ Pivot table (product_supplier) for efficient relationship management
    ✅ Duplicate prevention via unique constraints
    ✅ Quick Add Supplier modal with simplified form (Individual/Business toggle, Name, Mobile)
    The implementation follows Ultimate POS conventions and maintains compatibility with existing features while supporting the flexibility of multiple suppliers per product.
    Key Differences from Single Supplier Implementation
    Uses belongsToMany instead of belongsTo/hasMany
    Requires pivot table (product_supplier)
    Form fields use supplier_ids[] instead of supplier_id
    Controllers use sync() method for relationship management
    Views display comma-separated supplier names
    Quick Add Supplier Features
    The simplified quick add supplier modal (similar to /purchases/create) provides:
    Individual/Business toggle - Switch between individual supplier (First Name, Last Name) and business supplier (Business Name)
    Minimum required fields - Only Supplier Name and Mobile Number are required
    Optional Email field - For additional contact information
    AJAX submission - Seamless addition without page reload
    Auto-select - Newly added supplier is automatically selected in the dropdown
  7. Vehicle Management System for Ultimate POS
    This comprehensive guide covers the complete implementation of a Vehicle Management system in Ultimate POS with Laravel, including CRUD operations, transaction integration, and reporting capabilities.
    Overview
    The Vehicle Management system provides:
    ✅ Complete CRUD operations for vehicles
    ✅ Detailed vehicle information tracking (model, license, insurance, etc.)
    ✅ Vehicle assignment to sales and purchase transactions
    ✅ Comprehensive vehicle reports and analytics
    ✅ Excel export functionality
    ✅ Multi-business support with data isolation
    ✅ Permission-based access control


    Screenshots
    Vehicle Index Page

    Vehicle Create Modal

    Vehicle Edit Modal

    Vehicle View Modal

    Vehicle Load Reports

    Sell list

    Sell create page

    POS create page

    Download Template for Phase 1
    vehicle-management-system-all-files.zip
    Database Setup
    Single Comprehensive Migration
    File: database/migrations/2025_09_29_180000_create_vehicle_management_system.php
    <?php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { /** * Run the migrations. * * @return void */ public function up() { // Create vehicles table with all fields Schema::create('vehicles', function (Blueprint $table) { $table->id(); $table->integer('business_id')->unsigned(); $table->foreign('business_id')->references('id')->on('business')->onDelete('cascade'); // Basic Information $table->string('vehicle_number')->unique(); $table->string('driver_name'); $table->string('vehicle_type'); $table->enum('status', ['active', 'inactive'])->default('active'); // Vehicle Details $table->string('vehicle_model')->nullable(); $table->string('license_plate')->nullable(); $table->string('vin_number')->nullable(); $table->year('year')->nullable(); $table->string('color')->nullable(); $table->string('fuel_type')->nullable(); $table->string('engine_capacity')->nullable(); // Operational Data $table->decimal('current_mileage', 10, 2)->nullable(); $table->date('purchase_date')->nullable(); $table->decimal('purchase_price', 22, 4)->nullable(); $table->date('insurance_expiry')->nullable(); $table->date('registration_expiry')->nullable(); $table->date('last_service_date')->nullable(); $table->date('next_service_due')->nullable(); // Performance Tracking $table->decimal('fuel_efficiency', 8, 2)->nullable()->comment('L/100km'); $table->decimal('max_load_capacity', 10, 2)->nullable()->comment('in tons'); $table->decimal('daily_rate', 22, 4)->nullable(); $table->decimal('cost_per_km', 22, 4)->nullable(); // Contact & Assignment $table->string('driver_phone')->nullable(); $table->string('driver_license')->nullable(); $table->string('assigned_route')->nullable(); $table->string('home_location')->nullable(); // System Fields $table->integer('created_by')->unsigned(); $table->timestamps(); $table->softDeletes(); // Indexes $table->index('business_id'); $table->index('status'); $table->index('vehicle_type'); }); // Add vehicle_id to transactions table Schema::table('transactions', function (Blueprint $table) { $table->unsignedBigInteger('vehicle_id')->nullable()->after('business_id'); $table->foreign('vehicle_id') ->references('id') ->on('vehicles') ->onDelete('set null'); $table->index('vehicle_id'); }); } /** * Reverse the migrations. * * @return void */ public function down() { // Remove vehicle_id from transactions table Schema::table('transactions', function (Blueprint $table) { $table->dropForeign(['vehicle_id']); $table->dropIndex(['vehicle_id']); $table->dropColumn('vehicle_id'); }); // Drop vehicles table Schema::dropIfExists('vehicles'); } }; Run Migration:
    # Run the migration php artisan migrate # Or run specific file php artisan migrate --path=database/migrations/2025_09_29_180000_create_vehicle_management_system.php Why Single Migration Approach:
    ✅ Simple: Everything in one file
    ✅ Complete: Full table structure from the start
    ✅ Clean: No dependency management needed
    ✅ Fast: One-step deployment
    ✅ Easy Rollback: Single rollback removes everything cleanly
    What This Creates:
    ✅ Complete vehicles table with all 28 fields
    ✅ vehicle_id link to transactions table
    ✅ Proper indexes for performance
    ✅ Foreign key with SET NULL on delete
    ✅ Soft deletes support
    ✅ Business-level data isolation
    Migration Management
    To check migration status:
    php artisan migrate:status | grep vehicle To rollback the migration:
    # Rollback the vehicle management system php artisan migrate:rollback --step=1 To rollback and re-run:
    php artisan migrate:refresh --step=1 Current Migration File:
    2025_09_29_180000_create_vehicle_management_system.php - Complete vehicle management system
    Model Implementation
    File: app/Vehicle.php
    <?php namespace App; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; class Vehicle extends Model { use SoftDeletes; protected $guarded = ['id']; protected $fillable = [ 'vehicle_number', 'driver_name', 'vehicle_type', 'status', 'business_id', 'created_by', // Vehicle Details 'vehicle_model', 'license_plate', 'vin_number', 'year', 'color', 'fuel_type', 'engine_capacity', // Operational Data 'current_mileage', 'purchase_date', 'purchase_price', 'insurance_expiry', 'registration_expiry', 'last_service_date', 'next_service_due', // Performance Tracking 'fuel_efficiency', 'max_load_capacity', 'daily_rate', 'cost_per_km', // Contact & Assignment 'driver_phone', 'driver_license', 'assigned_route', 'home_location' ]; protected $casts = [ 'purchase_date' => 'date', 'insurance_expiry' => 'date', 'registration_expiry' => 'date', 'last_service_date' => 'date', 'next_service_due' => 'date', 'current_mileage' => 'decimal:2', 'purchase_price' => 'decimal:2', 'fuel_efficiency' => 'decimal:2', 'max_load_capacity' => 'decimal:2', 'daily_rate' => 'decimal:2', 'cost_per_km' => 'decimal:2', ]; /** * Get vehicles for dropdown */ public static function forDropdown($business_id, $show_none = false) { $vehicles = Vehicle::where('business_id', $business_id) ->where('status', 'active') ->orderBy('vehicle_number', 'asc') ->pluck('vehicle_number', 'id'); if ($show_none) { $vehicles->prepend(__('lang_v1.none'), ''); } return $vehicles; } /** * Accessor: Display name */ public function getDisplayNameAttribute() { return $this->vehicle_number . ' (' . $this->driver_name . ')'; } /** * Accessor: Full details */ public function getFullDetailsAttribute() { $details = $this->vehicle_number; if ($this->vehicle_model) { $details .= ' - ' . $this->vehicle_model; } if ($this->license_plate) { $details .= ' (' . $this->license_plate . ')'; } return $details; } /** * Scopes for filtering */ public function scopeActive($query) { return $query->where('status', 'active'); } public function scopeByFuelType($query, $fuel_type) { return $query->where('fuel_type', $fuel_type); } public function scopeInsuranceExpiringSoon($query, $days = 30) { return $query->whereDate('insurance_expiry', '<=', now()->addDays($days)) ->whereDate('insurance_expiry', '>=', now()); } } Controller Implementation
    File: app/Http/Controllers/VehicleController.php
    The controller includes:
    ✅ Index with DataTables
    ✅ Create/Store methods
    ✅ Edit/Update methods
    ✅ Delete (soft delete)
    ✅ Show (detail view)
    ✅ Reports method
    ✅ Excel export
    See docs/Vehicle-Management-System/files/VehicleController.php for complete code.
    View Templates
    All view files are located in resources/views/vehicle/:
    1. Index Page (index.blade.php)
    Lists all vehicles in DataTable
    Search and filter functionality
    Action buttons (Edit, Delete, View)
    2. Create Modal (create.blade.php)
    Full vehicle form with all fields
    Validation
    Ajax submission
    3. Edit Modal (edit.blade.php)
    Pre-populated form
    Update functionality
    4. Show Page (show.blade.php)
    Detailed vehicle information
    Related transactions
    5. Reports Page (reports.blade.php)
    Vehicle analytics
    Transaction history
    Excel export
    All view files are available in docs/Vehicle-Management-System/files/vehicle/
    Transaction Integration
    Sales (POS) Integration
    1. Add vehicle selection to POS create form:
    Edit resources/views/sale_pos/partials/pos_form.blade.php:
    <!-- Vehicle Selection --> <div class="col-md-4 col-sm-6"> <div class="form-group"> <label>@lang('lang_v1.vehicle'):</label> <div class="input-group"> <span class="input-group-addon"> <i class="fa fa-car"></i> </span> {!! Form::select('vehicle_id', $vehicles, null, [ 'class' => 'form-control select2', 'id' => 'vehicle_id', 'placeholder' => __('lang_v1.select_vehicle') ]) !!} </div> </div> </div> 2. Add vehicle selection to POS edit form:
    Edit resources/views/sale_pos/partials/pos_form_edit.blade.php:
    <!-- Vehicle Selection Row --> <div class="row"> <div class="col-md-4 col-sm-6"> <div class="form-group"> <label>@lang('lang_v1.vehicle'):</label> <div class="input-group"> <span class="input-group-addon"> <i class="fa fa-car"></i> </span> {!! Form::select('vehicle_id', $vehicles, $transaction->vehicle_id, [ 'class' => 'form-control select2', 'id' => 'vehicle_id', 'placeholder' => __('lang_v1.select_vehicle') ]) !!} </div> </div> </div> </div> 3. Update SellPosController:
    In app/Http/Controllers/SellPosController.php:
    // In create() method - add vehicles to view $vehicles = \App\Vehicle::forDropdown($business_id, true); return view('sale_pos.create') ->with(compact(..., 'vehicles')); // In edit() method - add vehicles to view $vehicles = \App\Vehicle::forDropdown($business_id, true); return view('sale_pos.edit') ->with(compact(..., 'vehicles')); // In store() method - save vehicle_id $input['vehicle_id'] = $request->has('vehicle_id') && !empty($request->input('vehicle_id')) ? $request->input('vehicle_id') : null; // In update() method - save vehicle_id $input['vehicle_id'] = $request->has('vehicle_id') && !empty($request->input('vehicle_id')) ? $request->input('vehicle_id') : null; 4. Update TransactionUtil:
    In app/Utils/TransactionUtil.php, add to updateSellTransaction() method:
    $update_date = [ // ... existing fields 'vehicle_id' => ! empty($input['vehicle_id']) ? $input['vehicle_id'] : null, // ... rest of fields ]; Purchase Integration
    1. Add vehicle selection to purchase create form:
    Edit resources/views/purchase/partials/purchase_entry_row.blade.php or main purchase form:
    <div class="col-sm-4"> <div class="form-group"> {!! Form::label('vehicle_id', __('lang_v1.vehicle') . ':') !!} <div class="input-group"> <span class="input-group-addon"> <i class="fa fa-car"></i> </span> {!! Form::select('vehicle_id', $vehicles, null, [ 'class' => 'form-control select2', 'placeholder' => __('lang_v1.select_vehicle') ]) !!} </div> </div> </div> 2. Update PurchaseController:
    Follow similar pattern as SellPosController to add vehicles to create/edit methods.
    Reporting System
    Export Class
    File: app/Exports/VehicleReportsExport.php
    Handles Excel export of vehicle transaction reports using Laravel Excel.
    Report Views
    1. Main Reports Page:
    Filter by date range
    Filter by vehicle
    Transaction type filter (sales/purchases/both)
    Summary statistics
    2. PDF Export: Partial template at resources/views/vehicle/partials/pdf_export.blade.php
    Menu & Permissions
    Add Menu Item
    Edit app/Http/Middleware/AdminSidebarMenu.php:
    // Vehicles menu (add before Settings Dropdown) if (auth()->user()->can('vehicle.view')) { $menu->url(action([\App\Http\Controllers\VehicleController::class, 'index']), __('lang_v1.vehicles'), [ 'icon' => '<svg aria-hidden="true" class="tw-size-5 tw-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path> <path d="M7 17m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"></path> <path d="M17 17m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"></path> <path d="M5 17h-2v-11a1 1 0 0 1 1 -1h9v12m-4 0h6m-6 -9h8l2 3v6"></path> </svg>', 'active' => request()->segment(1) == 'vehicles' ])->order(85); } Add Permissions
    Run SQL or add to seeder:
    INSERT INTO permissions (name, guard_name, created_at, updated_at) VALUES ('vehicle.view', 'web', NOW(), NOW()), ('vehicle.create', 'web', NOW(), NOW()), ('vehicle.update', 'web', NOW(), NOW()), ('vehicle.delete', 'web', NOW(), NOW()); -- Assign to Admin role (adjust role_id as needed) INSERT INTO role_has_permissions (permission_id, role_id) SELECT id, 1 FROM permissions WHERE name LIKE 'vehicle.%'; Add Routes
    Edit routes/web.php:
    Route::resource('vehicles', 'VehicleController'); Route::get('vehicles-reports', 'VehicleController@reports'); Route::get('vehicles-reports/export', 'VehicleController@exportReport'); Language Keys
    Add to resources/lang/en/lang_v1.php:
    // Vehicles 'vehicles' => 'Vehicles', 'vehicle' => 'Vehicle', 'select_vehicle' => 'Select Vehicle', 'vehicle_help' => 'Optional: Assign a vehicle to this transaction', 'manage_your_vehicles' => 'Manage your vehicles', 'all_your_vehicles' => 'All Your Vehicles', 'add_vehicle' => 'Add Vehicle', 'edit_vehicle' => 'Edit Vehicle', 'vehicle_details' => 'Vehicle Details', 'vehicle_number' => 'Vehicle Number', 'driver_name' => 'Driver Name', 'driver_phone' => 'Driver Phone', 'driver_license' => 'Driver License', 'vehicle_type' => 'Vehicle Type', 'vehicle_model' => 'Vehicle Model', 'license_plate' => 'License Plate', 'vin_number' => 'VIN Number', 'fuel_type' => 'Fuel Type', 'engine_capacity' => 'Engine Capacity', 'current_mileage' => 'Current Mileage', 'purchase_price' => 'Purchase Price', 'insurance_expiry' => 'Insurance Expiry', 'registration_expiry' => 'Registration Expiry', 'last_service_date' => 'Last Service Date', 'next_service_due' => 'Next Service Due', 'fuel_efficiency' => 'Fuel Efficiency (L/100km)', 'max_load_capacity' => 'Max Load Capacity', 'daily_rate' => 'Daily Rate', 'cost_per_km' => 'Cost Per KM', 'assigned_route' => 'Assigned Route', 'home_location' => 'Home Location', 'vehicle_added_success' => 'Vehicle added successfully', 'vehicle_updated_success' => 'Vehicle updated successfully', 'vehicle_deleted_success' => 'Vehicle deleted successfully', 'vehicle_reports' => 'Vehicle Reports', 'vehicle_transactions' => 'Vehicle Transactions', 'no_vehicle_assigned' => 'No Vehicle Assigned', Complete File Package
    All implementation files are available in the files directory:
    Structure:
    docs/Vehicle-Management-System/files/ ├── Vehicle.php # Model ├── VehicleController.php # Controller ├── VehicleReportsExport.php # Excel Export ├── migrations/ │ ├── 2025_09_29_180000_create_vehicle_management_system.php │ └── create_vehicle_management_system.php # Alternative version └── vehicle/ # Views ├── index.blade.php ├── create.blade.php ├── edit.blade.php ├── show.blade.php ├── reports.blade.php └── partials/ └── pdf_export.blade.php Installation Steps
    Copy Model:
    cp files/Vehicle.php app/ Copy Controller:
    cp files/VehicleController.php app/Http/Controllers/ Copy Export:
    cp files/VehicleReportsExport.php app/Exports/ Copy Views:
    cp -r files/vehicle resources/views/ Copy Migration:
    cp files/migrations/2025_09_29_180000_create_vehicle_management_system.php database/migrations/ Run Migration:
    php artisan migrate Add Routes, Menu, Permissions (see sections above)
    Features Summary
    ✅ Complete CRUD Operations
    Create vehicles with comprehensive details
    Edit existing vehicles
    View individual vehicle details
    Soft delete vehicles
    Status management (active/inactive)
    ✅ Transaction Integration
    Assign vehicles to sales transactions
    Assign vehicles to purchase transactions
    Track vehicle usage across all transactions
    Filter transactions by vehicle
    ✅ Advanced Reporting
    Vehicle transaction history
    Sales and purchase summaries by vehicle
    Date range filtering
    Excel export functionality
    Performance metrics
    ✅ Business Features
    Multi-business support
    Permission-based access control
    Business-level data isolation
    Soft deletes for data integrity
    Audit trail (created_by tracking)
    ✅ User Experience
    DataTables with server-side processing
    Ajax-powered modals
    Select2 dropdowns
    Responsive design
    Consistent with Ultimate POS UI
    Testing
    Sample Data
    INSERT INTO vehicles (vehicle_number, driver_name, vehicle_type, vehicle_model, license_plate, status, business_id, created_by, created_at, updated_at) VALUES ('TRK-001', 'John Smith', 'Truck', 'Ford F-150', 'ABC-1234', 'active', 1, 1, NOW(), NOW()), ('VAN-002', 'Mike Johnson', 'Van', 'Mercedes Sprinter', 'XYZ-5678', 'active', 1, 1, NOW(), NOW()), ('TRK-003', 'David Wilson', 'Heavy Truck', 'Volvo FH16', 'DEF-9012', 'active', 1, 1, NOW(), NOW()); Test Checklist
     Create new vehicle
     Edit vehicle details
     Delete vehicle (soft delete)
     View vehicle details
     Assign vehicle to sale
     Assign vehicle to purchase
     Filter by vehicle in reports
     Export vehicle report to Excel
     Check permissions (view/create/edit/delete)
     Test multi-business isolation
    Troubleshooting
    Common Issues
    1. Vehicle not showing in dropdown:
    Check vehicle status is 'active'
    Verify business_id matches current business
    Clear cache: php artisan cache:clear
    2. Migration errors:
    Ensure transactions table exists first
    Check foreign key constraints
    Verify business table exists for foreign key reference
    3. Permission denied:
    Verify permissions are seeded
    Check role assignments
    Clear permission cache: php artisan permission:cache-reset
    4. Table already exists error: If you get "table vehicles already exists" error:
    # Check current status php artisan migrate:status | grep vehicle # If needed, rollback and start fresh php artisan migrate:rollback --step=1 php artisan migrate 5. Foreign key constraint errors:
    # Make sure transactions table exists mysql -u username -p database_name -e "SHOW TABLES LIKE 'transactions';" # If vehicle_id column exists but causing issues mysql -u username -p database_name -e "SHOW COLUMNS FROM transactions WHERE Field='vehicle_id';" # Manual cleanup if needed (use with caution) mysql -u username -p database_name -e "ALTER TABLE transactions DROP FOREIGN KEY transactions_vehicle_id_foreign;" mysql -u username -p database_name -e "ALTER TABLE transactions DROP COLUMN vehicle_id;" 6. Migration partially applied: If migration fails midway:
    # Check what exists php artisan migrate:status | grep vehicle mysql -u username -p database_name -e "SHOW TABLES LIKE 'vehicles';" # Force rollback php artisan migrate:rollback --step=1 --force # Or manual cleanup mysql -u username -p database_name -e "DROP TABLE IF EXISTS vehicles;" mysql -u username -p database_name -e "ALTER TABLE transactions DROP COLUMN IF EXISTS vehicle_id;" # Delete migration record mysql -u username -p database_name -e "DELETE FROM migrations WHERE migration LIKE '%vehicle%';" # Run again php artisan migrate Compatible with: Ultimate POS 6.x+ Laravel Version: 9.x / 10.x
  8. Separating Purchase and Sell Report Permissions

    This guide explains how to split the combined purchase_n_sell_report.view permission into separate purchase_report.view and sell_report.view permissions in Ultimate POS Laravel application.
    Overview
    Currently, Ultimate POS uses a single permission purchase_n_sell_report.view to control access to both purchase and sell reports. This guide will show you how to separate these into two distinct permissions for better role-based access control.

    Files to Modify
    The following files need to be updated:
    database/seeders/PermissionsTableSeeder.php - Add new permissions
    app/Http/Middleware/AdminSidebarMenu.php - Update menu visibility logic
    app/Http/Controllers/ReportController.php - Update permission checks
    resources/views/role/create.blade.php - Update role creation form
    resources/views/role/edit.blade.php - Update role editing form
    Step-by-Step Implementation
    Step 1: Add New Permissions to Seeder
    Update database/seeders/PermissionsTableSeeder.php:
    <?php namespace Database\Seeders; use Illuminate\Database\Seeder; use Spatie\Permission\Models\Permission; class PermissionsTableSeeder extends Seeder { public function run() { $data = [ // ... existing permissions ... // Replace the old combined permission with separate ones // ['name' => 'purchase_n_sell_report.view'], // Remove this line ['name' => 'purchase_report.view'], // Add this ['name' => 'sell_report.view'], // Add this // ... rest of existing permissions ... ]; $insert_data = []; $time_stamp = \Carbon::now()->toDateTimeString(); foreach ($data as $d) { $d['guard_name'] = 'web'; $d['created_at'] = $time_stamp; $insert_data[] = $d; } Permission::insert($insert_data); } } Step 2: Update AdminSidebarMenu Middleware
    In app/Http/Middleware/AdminSidebarMenu.php, update the Reports dropdown section:
    // Find the Reports dropdown section and update the permission check if ( auth()->user()->can('purchase_report.view') || // New permission auth()->user()->can('sell_report.view') || // New permission auth()->user()->can('contacts_report.view') || auth()->user()->can('stock_report.view') || auth()->user()->can('tax_report.view') || auth()->user()->can('trending_product_report.view') || auth()->user()->can('sales_representative.view') || auth()->user()->can('register_report.view') || auth()->user()->can('expense_report.view') ) { $menu->dropdown( __('report.reports'), function ($sub) use ($enabled_modules, $is_admin) { // ... other report menu items ... // Update the purchase & sell report condition if ((in_array('purchases', $enabled_modules) || in_array('add_sale', $enabled_modules) || in_array('pos_sale', $enabled_modules)) && (auth()->user()->can('purchase_report.view') || auth()->user()->can('sell_report.view'))) { $sub->url( action([\App\Http\Controllers\ReportController::class, 'getPurchaseSell']), __('report.purchase_sell_report'), ['icon' => '', 'active' => request()->segment(2) == 'purchase-sell'] ); } // ... rest of menu items ... }, ['icon' => '...', 'id' => 'tour_step8'] )->order(55); } Step 3: Update ReportController
    In app/Http/Controllers/ReportController.php, update the permission checks:
    /** * Shows product report of a business * * @return \Illuminate\Http\Response */ public function getPurchaseSell(Request $request) { // Update permission check to allow either permission if (!auth()->user()->can('purchase_report.view') && !auth()->user()->can('sell_report.view')) { abort(403, 'Unauthorized action.'); } $business_id = $request->session()->get('user.business_id'); //Return the details in ajax call if ($request->ajax()) { $start_date = $request->get('start_date'); $end_date = $request->get('end_date'); $location_id = $request->get('location_id'); $purchase_details = []; $sell_details = []; $transaction_totals = []; // Only fetch purchase data if user has purchase report permission if (auth()->user()->can('purchase_report.view')) { $purchase_details = $this->transactionUtil->getPurchaseTotals($business_id, $start_date, $end_date, $location_id); } // Only fetch sell data if user has sell report permission if (auth()->user()->can('sell_report.view')) { $sell_details = $this->transactionUtil->getSellTotals( $business_id, $start_date, $end_date, $location_id ); } // Only fetch transaction totals if user has either permission if (auth()->user()->can('purchase_report.view') || auth()->user()->can('sell_report.view')) { $transaction_types = [ 'purchase_return', 'sell_return', ]; $transaction_totals = $this->transactionUtil->getTransactionTotals( $business_id, $transaction_types, $start_date, $end_date, $location_id ); } $total_purchase_return_inc_tax = $transaction_totals['total_purchase_return_inc_tax'] ?? 0; $total_sell_return_inc_tax = $transaction_totals['total_sell_return_inc_tax'] ?? 0; $difference = [ 'total' => ($sell_details['total_sell_inc_tax'] ?? 0) - $total_sell_return_inc_tax - (($purchase_details['total_purchase_inc_tax'] ?? 0) - $total_purchase_return_inc_tax), 'due' => ($sell_details['invoice_due'] ?? 0) - ($purchase_details['purchase_due'] ?? 0), ]; return ['purchase' => $purchase_details, 'sell' => $sell_details, 'total_purchase_return' => $total_purchase_return_inc_tax, 'total_sell_return' => $total_sell_return_inc_tax, 'difference' => $difference, ]; } $business_locations = BusinessLocation::forDropdown($business_id, true); return view('report.purchase_sell') ->with(compact('business_locations')); } // Add similar updates to other report methods that used the old permission: /** * Shows product purchase report */ public function getproductPurchaseReport(Request $request) { if (!auth()->user()->can('purchase_report.view')) { abort(403, 'Unauthorized action.'); } // ... rest of the method } /** * Shows product sell report */ public function getproductSellReport(Request $request) { if (!auth()->user()->can('sell_report.view')) { abort(403, 'Unauthorized action.'); } // ... rest of the method } /** * Shows purchase payment report */ public function purchasePaymentReport(Request $request) { if (!auth()->user()->can('purchase_report.view')) { abort(403, 'Unauthorized action.'); } // ... rest of the method } /** * Shows sell payment report */ public function sellPaymentReport(Request $request) { if (!auth()->user()->can('sell_report.view')) { abort(403, 'Unauthorized action.'); } // ... rest of the method } /** * Shows items report */ public function itemsReport() { if (!auth()->user()->can('purchase_report.view') && !auth()->user()->can('sell_report.view')) { abort(403, 'Unauthorized action.'); } // ... rest of the method } Step 4: Update Role Creation Form
    In resources/views/role/create.blade.php, replace the combined permission checkbox:
    <div class="row check_group"> <div class="col-md-1"> <h4>@lang( 'role.report' )</h4> </div> <div class="col-md-2"> <div class="checkbox"> <label> <input type="checkbox" class="check_all input-icheck" > {{ __( 'role.select_all' ) }} </label> </div> </div> <div class="col-md-9"> {{-- Remove this block: @if(in_array('purchases', $enabled_modules) || in_array('add_sale', $enabled_modules) || in_array('pos_sale', $enabled_modules)) <div class="col-md-12"> <div class="checkbox"> <label> {!! Form::checkbox('permissions[]', 'purchase_n_sell_report.view', false, [ 'class' => 'input-icheck']); !!} {{ __( 'role.purchase_n_sell_report.view' ) }} </label> </div> </div> @endif --}} {{-- Add these separate checkboxes: --}} @if(in_array('purchases', $enabled_modules)) <div class="col-md-12"> <div class="checkbox"> <label> {!! Form::checkbox('permissions[]', 'purchase_report.view', false, [ 'class' => 'input-icheck']); !!} {{ __( 'role.purchase_report.view' ) }} </label> </div> </div> @endif @if(in_array('add_sale', $enabled_modules) || in_array('pos_sale', $enabled_modules)) <div class="col-md-12"> <div class="checkbox"> <label> {!! Form::checkbox('permissions[]', 'sell_report.view', false, [ 'class' => 'input-icheck']); !!} {{ __( 'role.sell_report.view' ) }} </label> </div> </div> @endif {{-- ... rest of existing report permissions ... --}} </div> </div> Step 5: Update Role Edit Form
    In resources/views/role/edit.blade.php, make the same changes as in the create form:
    <div class="row check_group"> <div class="col-md-1"> <h4>@lang( 'role.report' )</h4> </div> <div class="col-md-2"> <div class="checkbox"> <label> <input type="checkbox" class="check_all input-icheck" > {{ __( 'role.select_all' ) }} </label> </div> </div> <div class="col-md-9"> {{-- Remove this block: @if(in_array('purchases', $enabled_modules) || in_array('add_sale', $enabled_modules) || in_array('pos_sale', $enabled_modules)) <div class="col-md-12"> <div class="checkbox"> <label> {!! Form::checkbox('permissions[]', 'purchase_n_sell_report.view', in_array('purchase_n_sell_report.view', $role_permissions), [ 'class' => 'input-icheck']); !!} {{ __( 'role.purchase_n_sell_report.view' ) }} </label> </div> </div> @endif --}} {{-- Add these separate checkboxes: --}} @if(in_array('purchases', $enabled_modules)) <div class="col-md-12"> <div class="checkbox"> <label> {!! Form::checkbox('permissions[]', 'purchase_report.view', in_array('purchase_report.view', $role_permissions), [ 'class' => 'input-icheck']); !!} {{ __( 'role.purchase_report.view' ) }} </label> </div> </div> @endif @if(in_array('add_sale', $enabled_modules) || in_array('pos_sale', $enabled_modules)) <div class="col-md-12"> <div class="checkbox"> <label> {!! Form::checkbox('permissions[]', 'sell_report.view', in_array('sell_report.view', $role_permissions), [ 'class' => 'input-icheck']); !!} {{ __( 'role.sell_report.view' ) }} </label> </div> </div> @endif {{-- ... rest of existing report permissions ... --}} </div> </div> Database Migration
    Create a migration to add the new permissions and remove the old one:
    php artisan make:migration update_purchase_sell_report_permissions <?php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Role; class UpdatePurchaseSellReportPermissions extends Migration { public function up() { // Create new permissions Permission::create(['name' => 'purchase_report.view', 'guard_name' => 'web']); Permission::create(['name' => 'sell_report.view', 'guard_name' => 'web']); // Find roles that have the old permission and give them both new permissions $oldPermission = Permission::where('name', 'purchase_n_sell_report.view')->first(); if ($oldPermission) { $roles = $oldPermission->roles; foreach ($roles as $role) { $role->givePermissionTo(['purchase_report.view', 'sell_report.view']); } // Remove the old permission $oldPermission->delete(); } } public function down() { // Recreate the old permission $oldPermission = Permission::create(['name' => 'purchase_n_sell_report.view', 'guard_name' => 'web']); // Find roles that have the new permissions and give them the old one $purchasePermission = Permission::where('name', 'purchase_report.view')->first(); $sellPermission = Permission::where('name', 'sell_report.view')->first(); $rolesWithPurchase = $purchasePermission ? $purchasePermission->roles : collect(); $rolesWithSell = $sellPermission ? $sellPermission->roles : collect(); $allRoles = $rolesWithPurchase->merge($rolesWithSell)->unique('id'); foreach ($allRoles as $role) { $role->givePermissionTo('purchase_n_sell_report.view'); } // Remove new permissions if ($purchasePermission) { $purchasePermission->delete(); } if ($sellPermission) { $sellPermission->delete(); } } } Language File Updates
    Add the new permission labels to your language files (e.g., resources/lang/en/role.php):
    'purchase_report.view' => 'View Purchase Reports', 'sell_report.view' => 'View Sell Reports', Testing
    After implementing these changes:
    Run the migration: php artisan migrate
    Clear application cache: php artisan cache:clear
    Test role creation and editing forms
    Test menu visibility with different permission combinations
    Test report access with the new permissions
    Benefits
    This separation provides:
    Granular Control: Assign purchase and sell report permissions independently
    Better Security: Users only see reports they need access to
    Flexible Roles: Create roles for purchase-only or sales-only staff
    Maintainable Code: Clearer permission structure for future development
    Notes
    Existing roles with the old purchase_n_sell_report.view permission will automatically get both new permissions through the migration
    Consider updating any custom views or components that might reference the old permission name
    Update any API endpoints that might check for the old permission
    Test thoroughly in a development environment before deploying to production
    Summary of Changes
    New Permissions: purchase_report.view and sell_report.view
    Removed Permission: purchase_n_sell_report.view
    Updated Files: 5 core files modified
    Migration: Automatic conversion of existing roles
    Backward Compatibility: Migration handles existing role assignments
    Step 6: Update Routes with Middleware Protection
    Update the routes in web.php to include proper middleware protection for the new permissions:
    //Reports... (Update existing report routes) Route::get('/reports/purchase-report', [ReportController::class, 'purchaseReport']) ->middleware('can:purchase_report.view'); Route::get('/reports/sale-report', [ReportController::class, 'saleReport']) ->middleware('can:sell_report.view'); // Combined report - requires either permission Route::get('/reports/purchase-sell', [ReportController::class, 'getPurchaseSell']) ->middleware('can:purchase_report.view,sell_report.view'); // Product-specific reports Route::get('/reports/product-purchase-report', [ReportController::class, 'getproductPurchaseReport']) ->middleware('can:purchase_report.view'); Route::get('/reports/product-sell-report', [ReportController::class, 'getproductSellReport']) ->middleware('can:sell_report.view'); Route::get('/reports/product-sell-report-with-purchase', [ReportController::class, 'getproductSellReportWithPurchase']) ->middleware('can:sell_report.view'); Route::get('/reports/product-sell-grouped-report', [ReportController::class, 'getproductSellGroupedReport']) ->middleware('can:sell_report.view'); Route::get('/reports/product-sell-grouped-by', [ReportController::class, 'productSellReportBy']) ->middleware('can:sell_report.view'); // Payment reports Route::get('/reports/purchase-payment-report', [ReportController::class, 'purchasePaymentReport']) ->middleware('can:purchase_report.view'); Route::get('/reports/sell-payment-report', [ReportController::class, 'sellPaymentReport']) ->middleware('can:sell_report.view'); // Items report - requires either permission Route::get('/reports/items-report', [ReportController::class, 'itemsReport']) ->middleware('can:purchase_report.view,sell_report.view'); Alternative Approach: Route Groups
    You can also organize the routes using groups for better maintainability:
    // Purchase Reports Group Route::middleware(['can:purchase_report.view'])->group(function () { Route::get('/reports/purchase-report', [ReportController::class, 'purchaseReport']); Route::get('/reports/product-purchase-report', [ReportController::class, 'getproductPurchaseReport']); Route::get('/reports/purchase-payment-report', [ReportController::class, 'purchasePaymentReport']); }); // Sell Reports Group Route::middleware(['can:sell_report.view'])->group(function () { Route::get('/reports/sale-report', [ReportController::class, 'saleReport']); Route::get('/reports/product-sell-report', [ReportController::class, 'getproductSellReport']); Route::get('/reports/product-sell-report-with-purchase', [ReportController::class, 'getproductSellReportWithPurchase']); Route::get('/reports/product-sell-grouped-report', [ReportController::class, 'getproductSellGroupedReport']); Route::get('/reports/product-sell-grouped-by', [ReportController::class, 'productSellReportBy']); Route::get('/reports/sell-payment-report', [ReportController::class, 'sellPaymentReport']); }); // Combined Reports (require either permission) Route::middleware(['can:purchase_report.view,sell_report.view'])->group(function () { Route::get('/reports/purchase-sell', [ReportController::class, 'getPurchaseSell']); Route::get('/reports/items-report', [ReportController::class, 'itemsReport']); }); Additional Considerations
    View Updates
    You may also need to update the report view templates to conditionally show purchase or sell sections based on user permissions:
    <!-- In report.purchase_sell view --> @if(auth()->user()->can('purchase_report.view')) <!-- Purchase report section --> <div class="purchase-section"> <!-- Purchase data display --> </div> @endif @if(auth()->user()->can('sell_report.view')) <!-- Sell report section --> <div class="sell-section"> <!-- Sell data display --> </div> @endif @if(!auth()->user()->can('purchase_report.view') && !auth()->user()->can('sell_report.view')) <div class="alert alert-warning"> {{ __('lang_v1.no_permission_for_this_report') }} </div> @endif This comprehensive guide should help you successfully separate the purchase and sell report permissions in Ultimate POS while maintaining backward compatibility and providing better granular access control.
  9. Fix Duplicate Records and Table Bloat

    This guide addresses a common issue in Ultimate POS where the transaction_sell_lines_purchase_lines table develops duplicate records and becomes bloated, consuming excessive disk space. This typically happens due to data synchronization issues or improper cleanup processes.

    Table showing excessive size before optimization

    Table size successfully reduced after applying the fix
    Symptoms
    Large table size (several GB) with relatively few records
    Duplicate entries in transaction data
    Poor database performance
    Disk space issues
    Prerequisites
    Backup Required
    Always create a database backup before performing these operations!
    Database administrator access
    phpMyAdmin or MySQL command line access
    Maintenance window (operations will lock the table)
    Solution
    Step 1: Verify the Issue
    First, check if you have duplicate records:
    SELECT stock_adjustment_line_id, purchase_line_id, quantity, qty_returned, created_at, updated_at, COUNT(*) as duplicate_count FROM transaction_sell_lines_purchase_lines GROUP BY stock_adjustment_line_id, purchase_line_id, quantity, qty_returned, created_at, updated_at HAVING COUNT(*) > 1; Check current table size:
    SELECT table_name, ROUND(((data_length + index_length) / 1024 / 1024), 2) AS "Size in MB" FROM information_schema.tables WHERE table_name = 'transaction_sell_lines_purchase_lines' AND table_schema = DATABASE(); Step 2: Remove Duplicate Records
    Table Lock Warning
    This operation will lock the table during execution. Perform during low-traffic periods.
    DELETE FROM transaction_sell_lines_purchase_lines WHERE id NOT IN ( SELECT min_id FROM ( SELECT MIN(id) as min_id FROM transaction_sell_lines_purchase_lines GROUP BY stock_adjustment_line_id, purchase_line_id, quantity, qty_returned, created_at, updated_at ) as keeper_ids ); This query:
    Keeps the record with the lowest id for each unique combination
    Removes all duplicate records
    Preserves data integrity by maintaining foreign key relationships
    Step 3: Force Table Rebuild (Fix Bloat)
    After removing duplicates, the table may still appear large because MySQL doesn't automatically reclaim space. Force a complete rebuild:
    -- Create new table with same structure CREATE TABLE transaction_sell_lines_purchase_lines_new LIKE transaction_sell_lines_purchase_lines; -- Copy all remaining data INSERT INTO transaction_sell_lines_purchase_lines_new SELECT * FROM transaction_sell_lines_purchase_lines; -- Replace the old table DROP TABLE transaction_sell_lines_purchase_lines; RENAME TABLE transaction_sell_lines_purchase_lines_new TO transaction_sell_lines_purchase_lines; Step 4: Verification
    Verify duplicates are gone:
    SELECT COUNT(*) as duplicate_groups FROM ( SELECT stock_adjustment_line_id, purchase_line_id, quantity, qty_returned, created_at, updated_at FROM transaction_sell_lines_purchase_lines GROUP BY stock_adjustment_line_id, purchase_line_id, quantity, qty_returned, created_at, updated_at HAVING COUNT(*) > 1 ) as dup_check; Check final table size:
    SELECT table_name, ROUND(((data_length + index_length) / 1024 / 1024), 2) AS "Size in MB" FROM information_schema.tables WHERE table_name = 'transaction_sell_lines_purchase_lines' AND table_schema = DATABASE(); Check record count:
    SELECT COUNT(*) as total_records FROM transaction_sell_lines_purchase_lines; Expected Results
    Duplicate groups: 0
    Table size: Significantly reduced (should be appropriate for the number of records)
    Record count: Only unique records remain
    Performance: Improved query speeds
    Troubleshooting
    phpMyAdmin LIMIT Error
    If you encounter "LIMIT 0, 25" errors when creating temporary tables, this is due to phpMyAdmin automatically adding pagination. Use the single-query approach provided in Step 2 instead.
    InnoDB Optimize Not Supported
    If you see "Table does not support optimize, doing recreate + analyze instead" - this is normal for InnoDB tables and should fix the bloat issue.
    Large Table Size Persists
    If the table remains large after optimization, use the force rebuild method in Step 3. This completely recreates the table structure and eliminates any remaining bloat.
    Prevention
    To prevent this issue from recurring:
    Regular maintenance: Schedule periodic duplicate checks
    Application review: Investigate why duplicates are being created
    Monitoring: Set up alerts for unusual table growth
    Backup strategy: Ensure regular backups before maintenance
    Additional Notes
    This procedure is specifically tested with Ultimate POS systems
    The table structure includes fields: id, sell_line_id, stock_adjustment_line_id, purchase_line_id, quantity, qty_returned, created_at, updated_at
    All operations should be performed during maintenance windows
    Consider upgrading Ultimate POS if this is a recurring issue
  10. Adding Price Groups to Product List And Purchase

    This guide covers how to implement dynamic selling price groups in the Ultimate POS product list and integrate them with the purchase system.
    Overview
    The price groups feature allows you to:
    Display multiple selling price groups as columns in the product list
    Manage group-specific pricing for products
    Update price group values from purchase operations



    📦 Download Starter Files
    price-groups-in-products-purchase.zip
    Prerequisites
    Ultimate POS Laravel application
    Basic understanding of Laravel, Blade templates, and DataTables
    Database with selling_price_groups and variation_group_prices tables
    Step 1: Display Price Groups in Product List
    1.1 Modify the Product List View Template
    File: resources/views/product/partials/product_list.blade.php
    Add the dynamic price group headers after the existing table headers:
    @foreach ($price_groups as $price_group_key => $price_group) @php $colspan++; @endphp <th>{{ $price_group->name }}</th> @endforeach Location: Insert this code block in the table header section, typically after the standard product columns (SKU, Category, Brand, etc.).
    Here is the complete code for product_list.blade.php:
    @php $colspan = 15; $custom_labels = json_decode(session('business.custom_labels'), true); @endphp <table class="table table-bordered table-striped ajax_view hide-footer" id="product_table"> <thead> <tr> <th><input type="checkbox" id="select-all-row" data-table-id="product_table"></th> <th>{{__('lang_v1.product_image')}}</th> <th>@lang('messages.action')</th> <th>@lang('sale.product')</th> @can('view_purchase_price') @php $colspan++; @endphp <th>@lang('lang_v1.unit_perchase_price')</th> @endcan @can('access_default_selling_price') @php $colspan++; @endphp <th>@lang('lang_v1.selling_price')</th> @endcan <th>@lang('report.current_stock')</th> <th>@lang('product.product_type')</th> @foreach ($price_groups as $price_group_key => $price_group) @php $colspan++; @endphp <th>{{ $price_group->name }}</th> @endforeach <th>@lang('product.category')</th> <th>@lang('product.brand')</th> <th>@lang('product.tax')</th> <th>@lang('product.sku')</th> <th> @lang('purchase.business_location') @show_tooltip(__('lang_v1.product_business_location_tooltip')) </th> <th id="cf_1">{{ $custom_labels['product']['custom_field_1'] ?? '' }}</th> <th id="cf_2">{{ $custom_labels['product']['custom_field_2'] ?? '' }}</th> <th id="cf_3">{{ $custom_labels['product']['custom_field_3'] ?? '' }}</th> <th id="cf_4">{{ $custom_labels['product']['custom_field_4'] ?? '' }}</th> <th id="cf_5">{{ $custom_labels['product']['custom_field_5'] ?? '' }}</th> <th id="cf_6">{{ $custom_labels['product']['custom_field_6'] ?? '' }}</th> <th id="cf_7">{{ $custom_labels['product']['custom_field_7'] ?? '' }}</th> </tr> </thead> <tfoot> <tr> <td colspan="{{$colspan}}"> <div style="display: flex; width: 100%;"> @can('product.delete') {!! Form::open(['url' => action([\App\Http\Controllers\ProductController::class, 'massDestroy']), 'method' => 'post', 'id' => 'mass_delete_form' ]) !!} {!! Form::hidden('selected_rows', null, ['id' => 'selected_rows']); !!} {!! Form::submit(__('lang_v1.delete_selected'), array('class' => 'tw-dw-btn tw-dw-btn-outline tw-dw-btn-xs tw-dw-btn-error', 'id' => 'delete-selected')) !!} {!! Form::close() !!} @endcan @can('product.update') @if(config('constants.enable_product_bulk_edit')) &nbsp; {!! Form::open(['url' => action([\App\Http\Controllers\ProductController::class, 'bulkEdit']), 'method' => 'post', 'id' => 'bulk_edit_form' ]) !!} {!! Form::hidden('selected_products', null, ['id' => 'selected_products_for_edit']); !!} <button type="submit" class="tw-dw-btn tw-dw-btn-xs tw-dw-btn-outline tw-dw-btn-primary" id="edit-selected"> <i class="fa fa-edit"></i>{{__('lang_v1.bulk_edit')}}</button> {!! Form::close() !!} @endif &nbsp; <button type="button" class="tw-dw-btn tw-dw-btn-xs tw-dw-btn-outline tw-dw-btn-accent update_product_location" data-type="add">@lang('lang_v1.add_to_location')</button> &nbsp; <button type="button" class="tw-dw-btn tw-dw-btn-xs tw-dw-btn-outline tw-dw-btn-neutral update_product_location" data-type="remove">@lang('lang_v1.remove_from_location')</button> @endcan &nbsp; {!! Form::open(['url' => action([\App\Http\Controllers\ProductController::class, 'massDeactivate']), 'method' => 'post', 'id' => 'mass_deactivate_form' ]) !!} {!! Form::hidden('selected_products', null, ['id' => 'selected_products']); !!} {!! Form::submit(__('lang_v1.deactivate_selected'), array('class' => 'tw-dw-btn tw-dw-btn-xs tw-dw-btn-outline tw-dw-btn-warning', 'id' => 'deactivate-selected')) !!} {!! Form::close() !!} @show_tooltip(__('lang_v1.deactive_product_tooltip')) &nbsp; @if($is_woocommerce) <button type="button" class="tw-dw-btn tw-dw-btn-xs tw-dw-btn-outline tw-dw-btn-warning toggle_woocomerce_sync"> @lang('lang_v1.woocommerce_sync') </button> @endif </div> </td> </tr> </tfoot> </table> 1.2 Update DataTable Column Configuration
    File: resources/views/product/index.blade.php
    Add the dynamic DataTable columns configuration:
    @foreach ($price_groups as $price_group) { data: 'group_price{{$price_group->id}}', name: 'group_price{{$price_group->id}}', searchable: false, orderable: false, width: '80px' }, @endforeach Location: Insert this within the DataTable columns array configuration, after the existing column definitions. Here is the complete code for the new DataTable columns:
    product_table = $('#product_table').DataTable({ processing: true, serverSide: true, fixedHeader: false, aaSorting: [ [3, 'asc'] ], scrollY: "75vh", scrollX: true, scrollCollapse: true, "ajax": { "url": "/products", "data": function(d) { d.type = $('#product_list_filter_type').val(); d.category_id = $('#product_list_filter_category_id').val(); d.brand_id = $('#product_list_filter_brand_id').val(); d.unit_id = $('#product_list_filter_unit_id').val(); d.tax_id = $('#product_list_filter_tax_id').val(); d.active_state = $('#active_state').val(); d.not_for_selling = $('#not_for_selling').is(':checked'); d.location_id = $('#location_id').val(); if ($('#repair_model_id').length == 1) { d.repair_model_id = $('#repair_model_id').val(); } if ($('#woocommerce_enabled').length == 1 && $('#woocommerce_enabled').is(':checked')) { d.woocommerce_enabled = 1; } d = __datatable_ajax_callback(d); } }, columnDefs: [{ "targets": [0, 1, 2], "orderable": false, "searchable": false }], columns: [{ data: 'mass_delete' }, { data: 'image', name: 'products.image', width: '40px' }, { data: 'action', name: 'action', width: '60px' }, { data: 'product', name: 'products.name', width: '160px' }, @can('view_purchase_price') { data: 'purchase_price', name: 'max_purchase_price', searchable: false, width: '60px' }, @endcan @can('access_default_selling_price') { data: 'selling_price', name: 'max_price', searchable: false, width: '60px' }, @endcan { data: 'current_stock', searchable: false, width: '80px' }, { data: 'type', name: 'products.type' }, @foreach ($price_groups as $price_group) { data: 'group_price{{$price_group->id}}', name: 'group_price{{$price_group->id}}', searchable: false, orderable: false, width: '80px' }, @endforeach { data: 'category', name: 'c1.name' }, { data: 'brand', name: 'brands.name' }, { data: 'tax', name: 'tax_rates.name', searchable: false }, { data: 'sku', name: 'products.sku' }, { data: 'product_locations', name: 'product_locations' }, { data: 'product_custom_field1', name: 'products.product_custom_field1', visible: $('#cf_1').text().length > 0 }, { data: 'product_custom_field2', name: 'products.product_custom_field2', visible: $('#cf_2').text().length > 0 }, { data: 'product_custom_field3', name: 'products.product_custom_field3', visible: $('#cf_3').text().length > 0 }, { data: 'product_custom_field4', name: 'products.product_custom_field4', visible: $('#cf_4').text().length > 0 }, { data: 'product_custom_field5', name: 'products.product_custom_field5', visible: $('#cf_5').text().length > 0 }, { data: 'product_custom_field6', name: 'products.product_custom_field6', visible: $('#cf_6').text().length > 0 }, { data: 'product_custom_field7', name: 'products.product_custom_field7', visible: $('#cf_7').text().length > 0 }, ], createdRow: function(row, data, dataIndex) { if ($('input#is_rack_enabled').val() == 1) { var target_col = 0; @can('product.delete') target_col = 1; @endcan $(row).find('td:eq(' + target_col + ') div').prepend( '<i style="margin:auto;" class="fa fa-plus-circle text-success cursor-pointer no-print rack-details" title="' + LANG.details + '"></i>&nbsp;&nbsp;'); } $(row).find('td:eq(0)').attr('class', 'selectable_td'); }, fnDrawCallback: function(oSettings) { __currency_convert_recursively($('#product_table')); }, }); 1.3 Enhanced Controller Implementation
    File: app/Http/Controllers/ProductController.php
    Key Changes in the index() method:
    1. Add Price Groups Query
    Before handling AJAX requests (if (request()->ajax()) {), add:
    // Get price groups for the business $price_groups = SellingPriceGroup::where('business_id', $business_id)->get(); 2. Dynamic Price Group Columns
    Add this code after the existing column definitions:
    Original Pattern:
    return Datatables::of($products) // ... column definitions ... ->rawColumns(['action', 'image', 'mass_delete', 'product', 'selling_price', 'purchase_price', 'category', 'current_stock']) ->make(true); Enhanced Pattern with Price Groups:
    // Define base raw columns $raw_columns = [ 'action', 'image', 'sku', 'mass_delete', 'product', 'selling_price', 'purchase_price', 'category', 'current_stock', ]; // Create DataTables instance $datatables = Datatables::of($products) ->addColumn('product_locations', function ($row) { return $row->product_locations->implode('name', ', '); }) // ... other existing column definitions ... ; // Add dynamic price group columns foreach ($price_groups as $price_group) { $column_name = 'group_price' . $price_group->id; $raw_columns[] = $column_name; $datatables->editColumn($column_name, function ($row) use ($price_group) { $due_html = ''; $group_price = (float) 0; if (!empty($price_group->id)) { $variation_group_price = VariationGroupPrice::where('variation_id', $row->variation_id) ->where('price_group_id', $price_group->id) ->first(); if (!empty($variation_group_price)) { $group_price = (float) $variation_group_price->price_inc_tax; } } $due_html .= '<span class="group_price" data-orig-value="' . $group_price . '">' . $this->productUtil->num_f($group_price, true) . '</span>'; return $due_html; }); } // Apply raw columns and return return $datatables->setRowAttr([ 'data-href' => function ($row) { if (auth()->user()->can('product.view')) { return action([\App\Http\Controllers\ProductController::class, 'view'], [$row->id]); } else { return ''; } }, ]) ->rawColumns($raw_columns) ->make(true); Complete index method:
    Add this import at the top of ProductController.php with other imports:
    use App\VariationGroupPrice; public function index() { if (!auth()->user()->can('product.view') && !auth()->user()->can('product.create')) { abort(403, 'Unauthorized action.'); } $business_id = request()->session()->get('user.business_id'); $selling_price_group_count = SellingPriceGroup::countSellingPriceGroups($business_id); $is_woocommerce = $this->moduleUtil->isModuleInstalled('Woocommerce'); // Get price groups for the business $price_groups = SellingPriceGroup::where('business_id', $business_id)->get(); if (request()->ajax()) { // Filter by location $location_id = request()->get('location_id', null); $permitted_locations = auth()->user()->permitted_locations(); $query = Product::with(['media']) ->leftJoin('brands', 'products.brand_id', '=', 'brands.id') ->join('units', 'products.unit_id', '=', 'units.id') ->leftJoin('categories as c1', 'products.category_id', '=', 'c1.id') ->leftJoin('categories as c2', 'products.sub_category_id', '=', 'c2.id') ->leftJoin('tax_rates', 'products.tax', '=', 'tax_rates.id') ->join('variations as v', 'v.product_id', '=', 'products.id') ->leftJoin('variation_location_details as vld', function ($join) use ($permitted_locations) { $join->on('vld.variation_id', '=', 'v.id'); if ($permitted_locations != 'all') { $join->whereIn('vld.location_id', $permitted_locations); } }) ->whereNull('v.deleted_at') ->where('products.business_id', $business_id) ->where('products.type', '!=', 'modifier'); if (!empty($location_id) && $location_id != 'none') { if ($permitted_locations == 'all' || in_array($location_id, $permitted_locations)) { $query->whereHas('product_locations', function ($query) use ($location_id) { $query->where('product_locations.location_id', '=', $location_id); }); } } elseif ($location_id == 'none') { $query->doesntHave('product_locations'); } else { if ($permitted_locations != 'all') { $query->whereHas('product_locations', function ($query) use ($permitted_locations) { $query->whereIn('product_locations.location_id', $permitted_locations); }); } else { $query->with('product_locations'); } } $products = $query->select( 'products.id', 'v.id as variation_id', // Critical for price groups 'products.name as product', 'products.type', 'c1.name as category', 'c2.name as sub_category', 'units.actual_name as unit', 'brands.name as brand', 'tax_rates.name as tax', 'products.sku', 'products.image', 'products.enable_stock', 'products.is_inactive', 'products.not_for_selling', 'products.product_custom_field1', 'products.product_custom_field2', 'products.product_custom_field3', 'products.product_custom_field4', 'products.product_custom_field5', 'products.product_custom_field6', 'products.product_custom_field7', 'products.product_custom_field8', 'products.product_custom_field9', 'products.product_custom_field10', 'products.product_custom_field11', 'products.product_custom_field12', 'products.product_custom_field13', 'products.product_custom_field14', 'products.product_custom_field15', 'products.product_custom_field16', 'products.product_custom_field17', 'products.product_custom_field18', 'products.product_custom_field19', 'products.product_custom_field20', 'products.alert_quantity', DB::raw('SUM(vld.qty_available) as current_stock'), DB::raw('MAX(v.sell_price_inc_tax) as max_price'), DB::raw('MIN(v.sell_price_inc_tax) as min_price'), DB::raw('MAX(v.dpp_inc_tax) as max_purchase_price'), DB::raw('MIN(v.dpp_inc_tax) as min_purchase_price') ); // If WooCommerce is enabled, add field to query if ($is_woocommerce) { $products->addSelect('woocommerce_disable_sync'); } $products->groupBy('products.id'); // Apply filters $type = request()->get('type', null); if (!empty($type)) { $products->where('products.type', $type); } $category_id = request()->get('category_id', null); if (!empty($category_id)) { $products->where('products.category_id', $category_id); } $brand_id = request()->get('brand_id', null); if (!empty($brand_id)) { $products->where('products.brand_id', $brand_id); } $unit_id = request()->get('unit_id', null); if (!empty($unit_id)) { $products->where('products.unit_id', $unit_id); } $tax_id = request()->get('tax_id', null); if (!empty($tax_id)) { $products->where('products.tax', $tax_id); } $active_state = request()->get('active_state', null); if ($active_state == 'active') { $products->Active(); } if ($active_state == 'inactive') { $products->Inactive(); } $not_for_selling = request()->get('not_for_selling', null); if ($not_for_selling == 'true') { $products->ProductNotForSales(); } $woocommerce_enabled = request()->get('woocommerce_enabled', 0); if ($woocommerce_enabled == 1) { $products->where('products.woocommerce_disable_sync', 0); } if (!empty(request()->get('repair_model_id'))) { $products->where('products.repair_model_id', request()->get('repair_model_id')); } // Create DataTables instance $datatables = Datatables::of($products) ->addColumn('product_locations', function ($row) { return $row->product_locations->implode('name', ', '); }) ->editColumn('category', '{{$category}} @if(!empty($sub_category))<br/> -- {{$sub_category}}@endif') ->addColumn('action', function ($row) use ($selling_price_group_count) { $html = '<div class="btn-group"><button type="button" class="tw-dw-btn tw-dw-btn-xs tw-dw-btn-outline tw-dw-btn-info tw-w-max dropdown-toggle" data-toggle="dropdown" aria-expanded="false">'.__('messages.actions').'<span class="caret"></span><span class="sr-only">Toggle Dropdown</span></button><ul class="dropdown-menu dropdown-menu-left" role="menu"><li><a href="'.action([\App\Http\Controllers\LabelsController::class, 'show']).'?product_id='.$row->id.'" data-toggle="tooltip" title="'.__('lang_v1.label_help').'"><i class="fa fa-barcode"></i> '.__('barcode.labels').'</a></li>'; if (auth()->user()->can('product.view')) { $html .= '<li><a href="'.action([\App\Http\Controllers\ProductController::class, 'view'], [$row->id]).'" class="view-product"><i class="fa fa-eye"></i> '.__('messages.view').'</a></li>'; } if (auth()->user()->can('product.update')) { $html .= '<li><a href="'.action([\App\Http\Controllers\ProductController::class, 'edit'], [$row->id]).'"><i class="glyphicon glyphicon-edit"></i> '.__('messages.edit').'</a></li>'; } if (auth()->user()->can('product.delete')) { $html .= '<li><a href="'.action([\App\Http\Controllers\ProductController::class, 'destroy'], [$row->id]).'" class="delete-product"><i class="fa fa-trash"></i> '.__('messages.delete').'</a></li>'; } if ($row->is_inactive == 1) { $html .= '<li><a href="'.action([\App\Http\Controllers\ProductController::class, 'activate'], [$row->id]).'" class="activate-product"><i class="fas fa-check-circle"></i> '.__('lang_v1.reactivate').'</a></li>'; } $html .= '<li class="divider"></li>'; if ($row->enable_stock == 1 && auth()->user()->can('product.opening_stock')) { $html .= '<li><a href="#" data-href="'.action([\App\Http\Controllers\OpeningStockController::class, 'add'], ['product_id' => $row->id]).'" class="add-opening-stock"><i class="fa fa-database"></i> '.__('lang_v1.add_edit_opening_stock').'</a></li>'; } if (auth()->user()->can('product.view')) { $html .= '<li><a href="'.action([\App\Http\Controllers\ProductController::class, 'productStockHistory'], [$row->id]).'"><i class="fas fa-history"></i> '.__('lang_v1.product_stock_history').'</a></li>'; } if (auth()->user()->can('product.create')) { if ($selling_price_group_count > 0) { $html .= '<li><a href="'.action([\App\Http\Controllers\ProductController::class, 'addSellingPrices'], [$row->id]).'"><i class="fas fa-money-bill-alt"></i> '.__('lang_v1.add_selling_price_group_prices').'</a></li>'; } $html .= '<li><a href="'.action([\App\Http\Controllers\ProductController::class, 'create'], ['d' => $row->id]).'"><i class="fa fa-copy"></i> '.__('lang_v1.duplicate_product').'</a></li>'; } if (!empty($row->media->first())) { $html .= '<li><a href="'.$row->media->first()->display_url.'" download="'.$row->media->first()->display_name.'"><i class="fas fa-download"></i> '.__('lang_v1.product_brochure').'</a></li>'; } $html .= '</ul></div>'; return $html; }) ->editColumn('product', function ($row) use ($is_woocommerce) { $product = $row->is_inactive == 1 ? e($row->product).' <span class="label bg-gray">'.__('lang_v1.inactive').'</span>' : e($row->product); $product = $row->not_for_selling == 1 ? $product.' <span class="label bg-gray">'.__('lang_v1.not_for_selling').'</span>' : $product; if ($is_woocommerce && !$row->woocommerce_disable_sync) { $product = $product.'<br><i class="fab fa-wordpress"></i>'; } return $product; }) ->editColumn('image', function ($row) { return '<div style="display: flex;"><img src="'.$row->image_url.'" alt="Product image" class="product-thumbnail-small"></div>'; }) ->editColumn('type', '@lang("lang_v1." . $type)') ->addColumn('mass_delete', function ($row) { return '<input type="checkbox" class="row-select" value="'.$row->id.'">'; }) ->editColumn('current_stock', function ($row) { if ($row->enable_stock) { $stock = $this->productUtil->num_f($row->current_stock, false, null, true); return $stock.' '.$row->unit; } else { return '--'; } }) ->addColumn('purchase_price', '<div style="white-space: nowrap;">@format_currency($min_purchase_price) @if($max_purchase_price != $min_purchase_price && $type == "variable") - @format_currency($max_purchase_price)@endif </div>') ->addColumn('selling_price', '<div style="white-space: nowrap;">@format_currency($min_price) @if($max_price != $min_price && $type == "variable") - @format_currency($max_price)@endif </div>') ->filterColumn('products.sku', function ($query, $keyword) { $query->whereHas('variations', function ($q) use ($keyword) { $q->where('sub_sku', 'like', "%{$keyword}%"); }) ->orWhere('products.sku', 'like', "%{$keyword}%"); }); // Define base raw columns $raw_columns = [ 'action', 'image', 'mass_delete', 'product', 'selling_price', 'purchase_price', 'category', 'current_stock', 'product_locations' ]; // Add dynamic price group columns foreach ($price_groups as $price_group) { $column_name = 'group_price' . $price_group->id; $raw_columns[] = $column_name; $datatables->editColumn($column_name, function ($row) use ($price_group) { try { $group_price = 0; if (isset($row->variation_id) && !empty($price_group->id)) { $variation_group_price = VariationGroupPrice::where('variation_id', $row->variation_id) ->where('price_group_id', $price_group->id) ->first(); if ($variation_group_price) { $group_price = (float) $variation_group_price->price_inc_tax; } } return '<span class="group_price" data-orig-value="' . $group_price . '">' . $this->productUtil->num_f($group_price, true) . '</span>'; } catch (\Exception $e) { \Log::error('Price group error: ' . $e->getMessage()); return '<span class="group_price" data-orig-value="0">0.00</span>'; } }); } // Apply final configuration and return return $datatables ->setRowAttr([ 'data-href' => function ($row) { if (auth()->user()->can('product.view')) { return action([\App\Http\Controllers\ProductController::class, 'view'], [$row->id]); } else { return ''; } }, ]) ->rawColumns($raw_columns) ->make(true); } // Non-AJAX request - return view with data $rack_enabled = (request()->session()->get('business.enable_racks') || request()->session()->get('business.enable_row') || request()->session()->get('business.enable_position')); $categories = Category::forDropdown($business_id, 'product'); $brands = Brands::forDropdown($business_id); $units = Unit::forDropdown($business_id); $tax_dropdown = TaxRate::forBusinessDropdown($business_id, false); $taxes = $tax_dropdown['tax_rates']; $business_locations = BusinessLocation::forDropdown($business_id); $business_locations->prepend(__('lang_v1.none'), 'none'); $show_manufacturing_data = $this->moduleUtil->isModuleInstalled('Manufacturing') && (auth()->user()->can('superadmin') || $this->moduleUtil->hasThePermissionInSubscription($business_id, 'manufacturing_module')); // List product screen filter from module $pos_module_data = $this->moduleUtil->getModuleData('get_filters_for_list_product_screen'); $is_admin = $this->productUtil->is_admin(auth()->user()); return view('product.index') ->with(compact( 'price_groups', 'rack_enabled', 'categories', 'brands', 'units', 'taxes', 'business_locations', 'show_manufacturing_data', 'pos_module_data', 'is_woocommerce', 'is_admin' )); } Key Changes Explained:
    Variable Declaration: $raw_columns array is created to manage column names dynamically
    DataTables Instance: Store the DataTables object in $datatables variable for manipulation
    Dynamic Addition: Price group columns are added to both the DataTables instance and the raw columns array
    Final Assembly: Apply rawColumns() and make() at the end
    Critical Addition - Missing variation_id:
    The original query is missing the essential variation_id field needed for price group lookup. Add this to your select statement:
    $products = $query->select( 'products.id', 'v.id as variation_id', // ADD THIS LINE - Essential for price groups! 'products.name as product', 'products.type', // ... rest of existing fields ... ); Complete Integration Steps:
    Add variation_id to select
    Get price groups before AJAX handling
    Replace direct rawColumns with variable approach
    Add price group processing loop
    Update rawColumns call to use the variable
    3. Pass Price Groups to View
    return view('product.index') ->with(compact( 'price_groups', // Add this line 'rack_enabled', 'categories', 'brands', 'units', 'taxes', 'business_locations', 'show_manufacturing_data', 'pos_module_data', 'is_woocommerce', 'is_admin' )); Key Implementation Notes
    Price Group Loop Position: The price group columns are positioned after the product type column and before category/brand columns
    Colspan Management: Each price group dynamically increments the $colspan variable for proper table footer spanning
    DataTable Integration: Price groups are added as non-searchable, non-orderable columns in the DataTable configuration
    Variation ID Requirement: The variation_id field is essential for linking price groups to specific product variations
    Dynamic Column Generation: Price group columns are generated based on actual database records, not hardcoded
    Testing
    Verify Price Groups Display: Check that price group columns appear in the product list
    Test Data Population: Ensure price group values display correctly for products with assigned group prices
    Verify Filtering: Test location and other filters work with the enhanced query
    Performance Check: Monitor query performance with the additional joins and data
    Step 2: Integration with Purchase System
    2.1 Add Price Group Columns to Purchase Create Form
    File: resources/views/purchase/create.blade.php
    Add price group column headers to the purchase product table. Locate the existing selling price header and add the price group headers after it:
    <th> @lang('purchase.unit_selling_price') <small>(@lang('product.inc_of_tax'))</small> </th> @foreach ($price_groups_all as $price_group_key => $price_group) <th>{{ $price_group->name }}</th> @endforeach Location: Add this code after the unit selling price header in the purchase table.
    2.2 Add Price Group Input Fields to Purchase Entry Row
    File: resources/views/purchase/partials/purchase_entry_row.blade.php
    Add price group input fields for each product row. Locate the lot number section and add the price group fields before it:
    @if(!empty($price_groups) && is_iterable($price_groups)) @foreach($price_groups as $price_group) <td> @php $group_price_group = $price_group->id; @endphp {!! Form::text('purchases['.$row_count.'][group_prices]['.$group_price_group.'][group_price]',!empty($variation_prices[$variation->id][$group_price_group]['price']) ? @num_format($variation_prices[$variation->id][$group_price_group]['price']) : 0, ['class' => 'form-control input_number input-sm']); !!} {!! Form::hidden('purchases['.$row_count.'][group_prices]['.$group_price_group.'][group_price_id]',$price_group->id); !!} @php $price_type = !empty($variation_prices[$variation->id][$group_price_group]['price_type']) ? $variation_prices[$variation->id][$group_price_group]['price_type'] : 'fixed'; @endphp <input type="hidden" name="purchases[{{$row_count}}][group_prices][{{$group_price_group}}][group_price_type]" value="{{ $price_type }}"> </td> @endforeach @endif Location: Add this code before the lot number section:
    @if(session('business.enable_lot_number')) @php $lot_number = !empty($imported_data['lot_number']) ? $imported_data['lot_number'] : null; @endphp <td> {!! Form::text('purchases[' . $row_count . '][lot_number]', $lot_number, ['class' => 'form-control input-sm']); !!} </td> @endif 2.3 Update Purchase Controller Methods
    File: app/Http/Controllers/PurchaseController.php
    2.3.1 Import Required Model
    At the top of the file with other imports, ensure you have:
    use App\SellingPriceGroup; 2.3.2 Update create() Method
    In the create() method, add price groups data before returning the view. Locate the existing code:
    $common_settings = !empty(session('business.common_settings')) ? session('business.common_settings') : []; $price_groups = SellingPriceGroup::forDropdown($business_id); $price_groups_all = SellingPriceGroup::where('business_id', $business_id)->get(); return view('purchase.create')->with( compact( 'price_groups', 'price_groups_all', 'taxes', 'orderStatuses', 'business_locations', 'currency_details', 'default_purchase_status', 'customer_groups', 'types', 'shortcuts', 'payment_line', 'payment_types', 'accounts', 'bl_attributes', 'common_settings' ) ); 2.3.3 Update edit() Method
    In the edit() method, ensure price groups are loaded and variation prices are prepared. Add this code before return view('purchase.edit'):
    $variation_prices = []; $price_groups = SellingPriceGroup::where('business_id', $business_id)->active()->get(); foreach ($purchase->purchase_lines as $key => $value) { if (!empty($value->sub_unit_id)) { $formated_purchase_line = $this->productUtil->changePurchaseLineUnit($value, $business_id); $purchase->purchase_lines[$key] = $formated_purchase_line; } foreach ($value->variations->group_prices as $group_price) { $variation_prices[$group_price->variation_id][$group_price->price_group_id] = [ 'price' => $group_price->price_inc_tax, 'price_type' => $group_price->price_type ]; } } Then include the price groups in the view compact:
    return view('purchase.edit') ->with(compact( 'price_groups', 'variation_prices', 'taxes', 'purchase', 'orderStatuses', 'business_locations', 'business', 'currency_details', 'default_purchase_status', 'customer_groups', 'types', 'shortcuts', 'purchase_orders', 'common_settings' )); 2.3.4 Update getPurchaseEntryRow() Method
    In the getPurchaseEntryRow() method, add price group data loading:
    public function getPurchaseEntryRow(Request $request) { if (request()->ajax()) { $product_id = $request->input('product_id'); $variation_id = $request->input('variation_id'); $business_id = request()->session()->get('user.business_id'); $location_id = $request->input('location_id'); $is_purchase_order = $request->has('is_purchase_order'); $supplier_id = $request->input('supplier_id'); $hide_tax = 'hide'; if ($request->session()->get('business.enable_inline_tax') == 1) { $hide_tax = ''; } $currency_details = $this->transactionUtil->purchaseCurrencyDetails($business_id); if (!empty($product_id)) { $row_count = $request->input('row_count'); $product = Product::where('id', $product_id) ->with(['unit', 'second_unit', 'variations.group_prices']) // ADD variations.group_prices ->first(); $sub_units = $this->productUtil->getSubUnits($business_id, $product->unit->id, false, $product_id); $query = Variation::where('product_id', $product_id) ->with([ 'product_variation', 'group_prices', // ADD this line 'variation_location_details' => function ($q) use ($location_id) { $q->where('location_id', $location_id); }, ]); if ($variation_id !== '0') { $query->where('id', $variation_id); } $variations = $query->get(); $taxes = TaxRate::where('business_id', $business_id) ->ExcludeForTaxGroup() ->get(); $last_purchase_line = $this->getLastPurchaseLine($variation_id, $location_id, $supplier_id); // ADD these lines for price groups $price_groups = SellingPriceGroup::where('business_id', $business_id)->get(); $variation_prices = []; foreach ($product->variations as $variation) { foreach ($variation->group_prices as $group_price) { $variation_prices[$variation->id][$group_price->price_group_id] = [ 'price' => $group_price->price_inc_tax, 'price_type' => $group_price->price_type ]; } } return view('purchase.partials.purchase_entry_row') ->with(compact( 'product', 'price_groups', // ADD this 'variation_prices', // ADD this 'variations', 'row_count', 'variation_id', 'taxes', 'currency_details', 'hide_tax', 'sub_units', 'is_purchase_order', 'last_purchase_line' )); } } } 2.3.5 Update importPurchaseProducts() Method
    In the importPurchaseProducts() method, add price group support for imported products before $html = view('purchase.partials.imported_purchase_product_rows'):
    $price_groups = SellingPriceGroup::where('business_id', $business_id)->get(); $variation_prices = []; foreach ($formatted_data as $data) { if (!empty($data['product'])) { foreach ($data['product']->variations as $variation) { foreach ($variation->group_prices as $group_price) { $variation_prices[$variation->id][$group_price->price_group_id] = [ 'price' => $group_price->price_inc_tax, 'price_type' => $group_price->price_type ]; } } } } $html = view('purchase.partials.imported_purchase_product_rows') ->with(compact('formatted_data', 'taxes', 'currency_details', 'hide_tax', 'row_count', 'price_groups', 'variation_prices'))->render(); 2.4 Add Price Group Columns to Purchase Edit Form
    File: resources/views/purchase/partials/edit_purchase_entry_row.blade.php
    Add price group column headers to the purchase edit table. Locate the existing selling price header and add the price group headers after it:
    @if(empty($is_purchase_order)) <th>@lang('purchase.unit_selling_price') <small>(@lang('product.inc_of_tax'))</small></th> @if(!empty($price_groups) && is_iterable($price_groups)) @foreach ($price_groups as $price_group_key => $price_group) <th>{{ $price_group->name }}</th> @endforeach @endif @if(session('business.enable_lot_number')) <th>@lang('lang_v1.lot_number')</th> @endif @if(session('business.enable_product_expiry')) <th>@lang('product.mfg_date') / @lang('product.exp_date')</th> @endif @endif Add price group input fields for each existing purchase line. Locate the selling price cell and add the price group fields after it:
    @if(empty($is_purchase_order)) <td> @if(session('business.enable_editing_product_from_purchase')) {!! Form::text('purchases[' . $loop->index . '][default_sell_price]', number_format($sp, $currency_precision, $currency_details->decimal_separator, $currency_details->thousand_separator), ['class' => 'form-control input-sm input_number default_sell_price', 'required']); !!} @else {{number_format($sp, $currency_precision, $currency_details->decimal_separator, $currency_details->thousand_separator)}} @endif </td> @if(!empty($price_groups) && is_iterable($price_groups)) @foreach($price_groups as $price_group) <td> @php $group_price_group = $price_group->id; $current_group_price = 0; if(isset($variation_prices[$purchase_line->variation_id][$group_price_group]['price'])) { $current_group_price = $variation_prices[$purchase_line->variation_id][$group_price_group]['price']; } $price_type = isset($variation_prices[$purchase_line->variation_id][$group_price_group]['price_type']) ? $variation_prices[$purchase_line->variation_id][$group_price_group]['price_type'] : 'fixed'; @endphp {!! Form::text('purchases['.$loop->index.'][group_prices]['.$group_price_group.'][group_price]', number_format($current_group_price, $currency_precision, $currency_details->decimal_separator, $currency_details->thousand_separator), ['class' => 'form-control input-sm input_number']); !!} {!! Form::hidden('purchases['.$loop->index.'][group_prices]['.$group_price_group.'][group_price_id]', $price_group->id); !!} <input type="hidden" name="purchases[{{$loop->index}}][group_prices][{{$group_price_group}}][group_price_type]" value="{{ $price_type }}"> </td> @endforeach @endif You can add a custom CSS rule to set minimum width for all form inputs on the edit page. Place this <style> block at the very top of your edit_purchase_entry_row.blade.php file, right after the PHP variables section:
    @php $hide_tax = ''; if(session()->get('business.enable_inline_tax') == 0){ $hide_tax = 'hide'; } $currency_precision = session('business.currency_precision', 2); $quantity_precision = session('business.quantity_precision', 2); @endphp {{-- Add a custom CSS rule to set minimum width for all form inputs --}} <style> #purchase_entry_table .form-control { min-width: 5rem; } </style> <div class="table-responsive"> <!-- rest of your table --> Or you can add a section CSS in resources/views/purchase/create.blade.php and resources/views/purchase/edit.blade.php pages:
    @section('css') <style> #purchase_entry_table .form-control { min-width: 5rem; } </style> @endsection
    2.5 Update ProductUtil for Price Group Processing
    File: app/Utils/ProductUtil.php
    Add the price group processing method to ProductUtil:
    public function createOrUpdateGroupPrice($group_prices, $variation_id) { if (!empty($variation_id)) { foreach ($group_prices as $key => $value) { // \Log::info("Processing group price key: $key", $value); // Check if the record already exists $variation_group_price = VariationGroupPrice::where('variation_id', $variation_id) ->where('price_group_id', $value['group_price_id']) ->first(); if (empty($variation_group_price)) { // \Log::info('Creating NEW variation group price record'); // Create new record $variation_group_price = new VariationGroupPrice(); $variation_group_price->variation_id = $variation_id; $variation_group_price->price_group_id = $value['group_price_id']; } else { // \Log::info('UPDATING existing variation group price record', [ // 'id' => $variation_group_price->id, // 'current_price' => $variation_group_price->price_inc_tax // ]); } // Convert price using num_uf $new_price = $this->num_uf($value['group_price']); // Update the values $variation_group_price->price_inc_tax = $new_price; $variation_group_price->price_type = $value['group_price_type']; // Save each record individually $result = $variation_group_price->save(); } } else { \Log::warning('Variation ID is empty - skipping price group update'); } } Update the createOrUpdatePurchaseLines method in ProductUtil to process price groups. Your createOrUpdatePurchaseLines method only processes the first element which only contains group_price 1. Elements 2-7 don't have product_id or variation_id, so they're skipped by the validation.
    Fix: Modify your createOrUpdatePurchaseLines method to handle this structure:
    /** * Add/Edit transaction purchase lines * * @param object $transaction * @param array $input_data * @param array $currency_details * @param bool $enable_product_editing * @param string $before_status = null * @return array */ public function createOrUpdatePurchaseLines($transaction, $input_data, $currency_details, $enable_product_editing, $before_status = null) { $updated_purchase_lines = []; $updated_purchase_line_ids = [0]; $exchange_rate = !empty($transaction->exchange_rate) ? $transaction->exchange_rate : 1; foreach ($input_data as $data) { // Skip if this is just a group_prices fragment without product data if (isset($data['group_prices']) && !isset($data['product_id'])) { // This is a price group fragment, merge it with the main purchase line continue; } // Validate required fields before processing if (!is_array($data) || empty($data['quantity']) || empty($data['product_id']) || empty($data['variation_id'])) { continue; } $multiplier = 1; if (isset($data['sub_unit_id']) && $data['sub_unit_id'] == $data['product_unit_id']) { unset($data['sub_unit_id']); } if (!empty($data['sub_unit_id'])) { $unit = Unit::find($data['sub_unit_id']); $multiplier = !empty($unit->base_unit_multiplier) ? $unit->base_unit_multiplier : 1; } $new_quantity = $this->num_uf($data['quantity']) * $multiplier; $new_quantity_f = $this->num_f($new_quantity); $old_qty = 0; //update existing purchase line if (isset($data['purchase_line_id'])) { $purchase_line = PurchaseLine::findOrFail($data['purchase_line_id']); $updated_purchase_line_ids[] = $purchase_line->id; $old_qty = $purchase_line->quantity; $this->updateProductStock($before_status, $transaction, $data['product_id'], $data['variation_id'], $new_quantity, $purchase_line->quantity, $currency_details); } else { //create newly added purchase lines $purchase_line = new PurchaseLine(); $purchase_line->product_id = $data['product_id']; $purchase_line->variation_id = $data['variation_id']; //Increase quantity only if status is received if ($transaction->status == 'received') { $this->updateProductQuantity($transaction->location_id, $data['product_id'], $data['variation_id'], $new_quantity_f, 0, $currency_details); } } // Collect all group prices from all array elements for this variation $all_group_prices = []; // Get group prices from current element if (isset($data['group_prices'])) { $all_group_prices = array_merge($all_group_prices, $data['group_prices']); } // Get group prices from other elements in the array foreach ($input_data as $other_data) { if (isset($other_data['group_prices']) && !isset($other_data['product_id'])) { $all_group_prices = array_merge($all_group_prices, $other_data['group_prices']); } } // Process all collected group prices if (!empty($all_group_prices)) { $this->createOrUpdateGroupPrice($all_group_prices, $data['variation_id']); } $purchase_line->quantity = $new_quantity; $purchase_line->pp_without_discount = ($this->num_uf($data['pp_without_discount'], $currency_details) * $exchange_rate) / $multiplier; $purchase_line->discount_percent = $this->num_uf($data['discount_percent'], $currency_details); $purchase_line->purchase_price = ($this->num_uf($data['purchase_price'], $currency_details) * $exchange_rate) / $multiplier; $purchase_line->purchase_price_inc_tax = ($this->num_uf($data['purchase_price_inc_tax'], $currency_details) * $exchange_rate) / $multiplier; $purchase_line->item_tax = ($this->num_uf($data['item_tax'], $currency_details) * $exchange_rate) / $multiplier; $purchase_line->tax_id = $data['purchase_line_tax_id']; $purchase_line->lot_number = !empty($data['lot_number']) ? $data['lot_number'] : null; $purchase_line->mfg_date = !empty($data['mfg_date']) ? $this->uf_date($data['mfg_date']) : null; $purchase_line->exp_date = !empty($data['exp_date']) ? $this->uf_date($data['exp_date']) : null; $purchase_line->sub_unit_id = !empty($data['sub_unit_id']) ? $data['sub_unit_id'] : null; $purchase_line->purchase_order_line_id = !empty($data['purchase_order_line_id']) ? $data['purchase_order_line_id'] : null; $purchase_line->purchase_requisition_line_id = !empty($data['purchase_requisition_line_id']) && $transaction->type == 'purchase_order' ? $data['purchase_requisition_line_id'] : null; if (!empty($data['secondary_unit_quantity'])) { $purchase_line->secondary_unit_quantity = $this->num_uf($data['secondary_unit_quantity']); } $updated_purchase_lines[] = $purchase_line; //Edit product price if ($enable_product_editing == 1 && $transaction->type == 'purchase') { if (isset($data['default_sell_price'])) { $variation_data['sell_price_inc_tax'] = ($this->num_uf($data['default_sell_price'], $currency_details)) / $multiplier; } $variation_data['pp_without_discount'] = ($this->num_uf($data['pp_without_discount'], $currency_details) * $exchange_rate) / $multiplier; $variation_data['variation_id'] = $purchase_line->variation_id; $variation_data['purchase_price'] = $purchase_line->purchase_price; $this->updateProductFromPurchase($variation_data); } if ($transaction->type == 'purchase_order') { //Update purchase requisition line quantity received $this->updatePurchaseOrderLine($purchase_line->purchase_requisition_line_id, $purchase_line->quantity, $old_qty); } //Update purchase order line quantity received $this->updatePurchaseOrderLine($purchase_line->purchase_order_line_id, $purchase_line->quantity, $old_qty); } //unset deleted purchase lines $delete_purchase_line_ids = []; $delete_purchase_lines = null; if (!empty($updated_purchase_line_ids)) { $delete_purchase_lines = PurchaseLine::where('transaction_id', $transaction->id) ->whereNotIn('id', $updated_purchase_line_ids) ->get(); if ($delete_purchase_lines->count()) { foreach ($delete_purchase_lines as $delete_purchase_line) { $delete_purchase_line_ids[] = $delete_purchase_line->id; //decrease deleted only if previous status was received if ($before_status == 'received') { $this->decreaseProductQuantity( $delete_purchase_line->product_id, $delete_purchase_line->variation_id, $transaction->location_id, $delete_purchase_line->quantity ); } //If purchase order line set decrease quantity if (!empty($delete_purchase_line->purchase_order_line_id)) { $this->updatePurchaseOrderLine($delete_purchase_line->purchase_order_line_id, 0, $delete_purchase_line->quantity); } //If purchase order line set decrease quantity if (!empty($delete_purchase_line->purchase_requisition_line_id)) { $this->updatePurchaseOrderLine($delete_purchase_line->purchase_requisition_line_id, 0, $delete_purchase_line->quantity); } } //unset if purchase order line from purchase lines if exists if ($transaction->type == 'purchase_order') { PurchaseLine::whereIn('purchase_order_line_id', $delete_purchase_line_ids) ->update(['purchase_order_line_id' => null]); } //Delete deleted purchase lines PurchaseLine::where('transaction_id', $transaction->id) ->whereIn('id', $delete_purchase_line_ids) ->delete(); } } //update purchase lines if (!empty($updated_purchase_lines)) { $transaction->purchase_lines()->saveMany($updated_purchase_lines); } return $delete_purchase_lines; } Location: Insert this code after the line where $purchase_line is created or found, and before the quantity assignment.
    2.6 Update Import Template and Processing
    File: import_purchase_products_template.xls
    The import template needs to be updated to include price group columns. The current template structure should be extended with additional columns for each price group.
    Add this import at the top of your PurchaseController.php file if it doesn't exist:
    use Maatwebsite\Excel\Facades\Excel; Original Template Structure:
    Column A: SKU (required) Column B: Quantity (required) Column C: Unit Cost Before Discount Column D: Discount Percent Column E: Tax Name Column F: Lot Number Column G: Manufacturing Date Column H: Expiry Date Updated Template Structure:
    Column A: SKU (required) Column B: Quantity (required) Column C: Unit Cost Before Discount Column D: Discount Percent Column E: Tax Name Column F: Lot Number Column G: Manufacturing Date Column H: Expiry Date Column I: Price Group 1 (if exists) Column J: Price Group 2 (if exists) Column K: Price Group 3 (if exists) ... (continue for each price group) Template Update Instructions:
    Add Dynamic Columns: The number of price group columns depends on how many selling price groups exist in your business
    Column Headers: Use the actual price group names as column headers
    Sample Data: Include sample price group values in the template
    Documentation: Update any accompanying documentation to explain the new columns
    Controller Update for Import Processing
    You'll also need to update the importPurchaseProducts() method to process the additional price group columns:
    public function importPurchaseProducts(Request $request) { try { $file = $request->file('file'); $parsed_array = Excel::toArray([], $file); //Remove header row $imported_data = array_splice($parsed_array[0], 1); $business_id = $request->session()->get('user.business_id'); $location_id = $request->input('location_id'); $row_count = $request->input('row_count'); // Get price groups for processing $price_groups = SellingPriceGroup::where('business_id', $business_id)->active()->get(); $formatted_data = []; $row_index = 0; $error_msg = ''; foreach ($imported_data as $key => $value) { $row_index = $key + 1; $temp_array = []; if (!empty($value[0])) { $variation = Variation::where('sub_sku', trim($value[0])) ->join('products', 'products.id', '=', 'variations.product_id') ->where('products.business_id', $business_id) ->with([ 'product_variation', 'variation_location_details' => function ($q) use ($location_id) { $q->where('location_id', $location_id); }, ]) ->select('variations.*') ->first(); $temp_array['variation'] = $variation; if (empty($variation)) { $error_msg = __('lang_v1.product_not_found_exception', ['row' => $row_index, 'sku' => $value[0]]); break; } $product = Product::where('id', $variation->product_id) ->where('business_id', $business_id) ->with(['unit']) ->first(); if (empty($product)) { $error_msg = __('lang_v1.product_not_found_exception', ['row' => $row_index, 'sku' => $value[0]]); break; } $temp_array['product'] = $product; $sub_units = $this->productUtil->getSubUnits($business_id, $product->unit->id, false, $product->id); $temp_array['sub_units'] = $sub_units; } else { $error_msg = __('lang_v1.product_not_found_exception', ['row' => $row_index, 'sku' => $value[0]]); break; } if (!empty($value[1])) { $temp_array['quantity'] = $value[1]; } else { $error_msg = __('lang_v1.quantity_required', ['row' => $row_index]); break; } $temp_array['unit_cost_before_discount'] = !empty($value[2]) ? $value[2] : $variation->default_purchase_price; $temp_array['discount_percent'] = !empty($value[3]) ? $value[3] : 0; $tax_id = null; if (!empty($value[4])) { $tax_name = trim($value[4]); $tax = TaxRate::where('business_id', $business_id) ->where('name', 'like', "%{$tax_name}%") ->first(); $tax_id = $tax->id ?? $tax_id; } $temp_array['tax_id'] = $tax_id; $temp_array['lot_number'] = !empty($value[5]) ? $value[5] : null; $temp_array['mfg_date'] = !empty($value[6]) ? $this->productUtil->format_date($value[6]) : null; $temp_array['exp_date'] = !empty($value[7]) ? $this->productUtil->format_date($value[7]) : null; // Process price group columns (starting from column I = index 8) $price_group_data = []; foreach ($price_groups as $index => $price_group) { $column_index = 8 + $index; // Starting after expiry date column if (isset($value[$column_index]) && !empty($value[$column_index])) { $price_group_data[$price_group->id] = [ 'group_price' => $value[$column_index], 'group_price_id' => $price_group->id, 'group_price_type' => 'fixed' ]; } } $temp_array['group_prices'] = $price_group_data; $formatted_data[] = $temp_array; } if (!empty($error_msg)) { return [ 'success' => false, 'msg' => $error_msg, ]; } $hide_tax = 'hide'; if ($request->session()->get('business.enable_inline_tax') == 1) { $hide_tax = ''; } $taxes = TaxRate::where('business_id', $business_id) ->ExcludeForTaxGroup() ->get(); $currency_details = $this->transactionUtil->purchaseCurrencyDetails($business_id); $price_groups = SellingPriceGroup::where('business_id', $business_id)->get(); $variation_prices = []; foreach ($formatted_data as $data) { if (!empty($data['product'])) { foreach ($data['product']->variations as $variation) { foreach ($variation->group_prices as $group_price) { $variation_prices[$variation->id][$group_price->price_group_id] = [ 'price' => $group_price->price_inc_tax, 'price_type' => $group_price->price_type ]; } } } } $html = view('purchase.partials.imported_purchase_product_rows') ->with(compact('formatted_data', 'taxes', 'currency_details', 'hide_tax', 'row_count', 'price_groups', 'variation_prices'))->render(); return [ 'success' => true, 'msg' => __('lang_v1.imported'), 'html' => $html, ]; } catch (\Exception $e) { return [ 'success' => false, 'msg' => $e->getMessage(), ]; } } Template Generation Script (Optional)
    You could create a dynamic template generator that creates the Excel file based on current price groups:
    public function downloadImportTemplate() { $business_id = request()->session()->get('user.business_id'); $price_groups = SellingPriceGroup::where('business_id', $business_id)->active()->get(); // Create Excel file with dynamic headers $headers = [ 'SKU', 'Quantity', 'Unit Cost Before Discount', 'Discount Percent', 'Tax Name', 'Lot Number', 'Manufacturing Date', 'Expiry Date' ]; // Add price group headers foreach ($price_groups as $price_group) { $headers[] = $price_group->name; } // Generate Excel file with these headers // ... Excel generation logic ... } Important Notes:
    Backward Compatibility: Ensure the import still works if price group columns are missing
    Error Handling: Add validation for price group values in the import process
    Documentation: Update user documentation to explain the new template format
    Template Versioning: Consider versioning your templates if you have existing users
    Key Implementation Features
    Dynamic Price Group Headers: Price group columns are generated dynamically based on active selling price groups
    Pre-populated Values: When editing purchases, existing price group values are loaded and displayed
    Form Validation: Price group inputs use the same validation as other price fields
    Import Support: Price groups are included in the product import functionality
    Consistent UI: Price group fields follow the same styling and behavior as other input fields
    Testing the Integration
    Create Purchase: Verify price group columns appear in the create purchase form
    Add Products: Ensure price group fields are populated when adding products to purchase
    Edit Purchase: Check that existing price group values are loaded correctly
    Import Products: Test that price group data is maintained during product import
    Data Persistence: Verify that price group values are saved and can be retrieved
    Troubleshooting
    Common Issues:
    Missing Price Group Columns: Ensure $price_groups_all is passed to the create view
    Empty Price Group Fields: Verify $variation_prices array is properly structured
    Form Submission Errors: Check that price group data structure matches expected format
    Import Issues: Ensure price group logic is included in import processing
    Performance Considerations:
    Database Queries: Monitor the impact of additional joins and queries
    Frontend Rendering: Test with multiple price groups to ensure responsive UI
    Memory Usage: Check memory consumption with large datasets
    Best Practices
    Error Handling: Always include try-catch blocks for price group operations
    Data Validation: Validate price group data before processing
    Logging: Add appropriate logging for debugging price group issues
    User Experience: Provide clear feedback when price groups are updated
    Next Steps
    With both Step 1 (Product List Display) and Step 2 (Purchase Integration) complete, you now have:
    ✅ Dynamic price group columns in product listings
    ✅ Price group management in purchase create/edit forms
    ✅ Automatic price group updates during purchase operations
    ✅ Support for product imports with price groups
    Your Ultimate POS now has full price group functionality integrated across the product and purchase systems!
  11. Camera Barcode Scanner

    Ultimate POS Camera Barcode Scanner Implementation Guide
    A comprehensive step-by-step tutorial for implementing a universal camera barcode scanner across all Ultimate POS modules.
    📋 Overview
    This guide will help you implement a reusable camera barcode scanner component that works across all Ultimate POS pages including Sales, Purchase, Stock Management, and POS systems.
     Camera scanner button integrated seamlessly in POS interface
    Features
    ✅ Universal compatibility across all modules
    ✅ Automatic page detection and appropriate behavior
    ✅ Reusable Blade component
    ✅ Fallback support for different search endpoints
    ✅ Multiple styling options
    ✅ HTTPS camera access with proper error handling
    ✅ Mobile-optimized scanning interface
    ✅ Real-time barcode detection
     Professional scanning interface with real-time camera feed
    🚀 Quick Start
    Prerequisites
    Ultimate POS system
    HTTPS connection (required for camera access)
    Modern browser with camera support
    Dependencies
    jQuery (already included in Ultimate POS)
    Html5Qrcode library
    Bootstrap (already included in Ultimate POS)
    📁 File Structure
    ├── resources/views/components/ │ └── camera-barcode-scanner.blade.php # Reusable component ├── public/js/ │ └── camera-barcode-scanner.js # Universal JavaScript └── resources/views/layouts/partials/ └── javascripts.blade.php # Include scripts 🛠️ Step 1: Create the Reusable Component
    Create the file resources/views/components/camera-barcode-scanner.blade.php:
    {{-- Camera Barcode Scanner Component Usage: <x-camera-barcode-scanner search-input-id="search_product" /> Props: - search-input-id: ID of the search input field (default: 'search_product') - button-class: Additional CSS classes for button (optional) - show-in-group: Whether to show in input-group-btn (default: true) - button-style: Button style - 'default', 'success', 'primary', 'link' (default: 'default') - full-width: Makes button full width (default: false) - button-text: Text for standalone buttons (default: 'Scan Barcode') --}} @props([ 'searchInputId' => 'search_product', 'buttonClass' => '', 'showInGroup' => true, 'buttonStyle' => 'default', 'fullWidth' => false, 'buttonText' => 'Scan Barcode' ]) @if($showInGroup) {{-- Camera Barcode Scanner Button for Input Groups --}} <button type="button" class="btn btn-default bg-white btn-flat camera-barcode-scanner-btn {{ $buttonClass }}" data-search-input="{{ $searchInputId }}" title="Scan Barcode"> <i class="fa fa-camera text-primary fa-lg"></i> </button> @else {{-- Standalone Camera Button --}} <button type="button" class="btn btn-{{ $buttonStyle }} camera-barcode-scanner-btn {{ $buttonClass }} @if($fullWidth) btn-block @endif" data-search-input="{{ $searchInputId }}" title="Scan Barcode"> <i class="fa fa-camera @if($buttonStyle === 'link') text-primary @endif"></i> {{ $buttonText }} </button> @endif {{-- Camera Scanner Modal (only include once per page) --}} @once <div id="camera_modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.9); z-index: 9999; text-align: center;"> <div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; padding: 20px; border-radius: 10px; max-width: 90%; max-height: 90%;"> <div style="margin-bottom: 15px;"> <h4 style="margin: 0; color: #333;">📷
    Barcode Scanner</h4>
    <button type="button"
    onclick="event.preventDefault(); event.stopPropagation(); CameraBarcodeScanner.closeModalOnly(); return false;"
    style="position: absolute; top: 10px; right: 15px; background: none; border: none; font-size: 20px; cursor: pointer;">&times;</button>
    </div>

    {{-- Html5Qrcode Scanner --}}
    <div id="reader" style="width: 100%; max-width: 500px; border: 2px solid #007bff; border-radius: 8px;"></div>

    <div id="status" style="margin-top: 15px; padding: 10px; background: #f8f9fa; border-radius: 5px; color: #333;">
    Starting camera...
    </div>
    </div>
    </div>

    <style>
    #barcode_scanner_btn:hover,
    .camera-barcode-scanner-btn:hover {
    background-color: #e3f2fd !important;
    border-color: #2196f3 !important;
    }

    #reader video {
    border-radius: 5px;
    }

    #camera_modal {
    backdrop-filter: blur(3px);
    }

    @media (max-width: 768px) {
    #camera_modal > div {
    width: 95% !important;
    max-width: none !important;
    }
    }
    </style>
    @endonce

    🔧 Step 2: Create the Universal JavaScript
    Create the file public/js/camera-barcode-scanner.js:
    /** * Universal Camera Barcode Scanner for Ultimate POS * Version: 2.0 * * Works with: POS, Sales, Purchase, Stock Adjustment, Transfers, etc. */ (function ($) { 'use strict'; window.CameraBarcodeScanner = { config: { cameraConfig: { facingMode: 'environment' }, scanConfig: { fps: 10, qrbox: { width: 300, height: 150 }, supportedScanTypes: [Html5QrcodeScanType.SCAN_TYPE_CAMERA], }, }, html5QrCode: null, isScanning: false, currentSearchInputId: 'search_product', currentPageType: null, init: function () { console.log('🎥
    Initializing Universal Camera Barcode Scanner...');
    try {
    this.html5QrCode = new Html5Qrcode('reader');
    this.detectPageType();
    this.bindEvents();
    } catch (error) {
    console.error(
    '❌ Camera scanner initialization failed:',
    error
    );
    }
    },

    detectPageType: function () {
    const currentUrl = window.location.pathname;

    if (currentUrl.includes('/pos/')) {
    this.currentPageType = 'pos';
    } else if (currentUrl.includes('/sells/')) {
    this.currentPageType = 'sell';
    } else if (
    currentUrl.includes('/purchases/') ||
    currentUrl.includes('/purchase-order/')
    ) {
    this.currentPageType = 'purchase';
    } else if (currentUrl.includes('/stock-adjustments/')) {
    this.currentPageType = 'stock_adjustment';
    } else if (currentUrl.includes('/stock-transfers/')) {
    this.currentPageType = 'transfer';
    } else if (currentUrl.includes('/purchase-return/')) {
    this.currentPageType = 'purchase_return';
    } else {
    this.currentPageType = 'general';
    }
    },

    bindEvents: function () {
    const self = this;

    $(document).on(
    'click',
    '.camera-barcode-scanner-btn, #barcode_scanner_btn',
    function (e) {
    e.preventDefault();
    e.stopPropagation();

    const searchInputId =
    $(this).data('search-input') || 'search_product';
    self.currentSearchInputId = searchInputId;
    self.openCamera();
    }
    );

    $(document).on('keydown', function (e) {
    if (e.ctrlKey && e.key === 'b') {
    e.preventDefault();
    $(
    '.camera-barcode-scanner-btn:visible, #barcode_scanner_btn:visible'
    )
    .first()
    .click();
    }
    });
    },

    openCamera: function () {
    if (!this.html5QrCode) return;
    if (
    location.protocol !== 'https:' &&
    location.hostname !== 'localhost'
    ) {
    alert('❌ Camera requires HTTPS connection');
    return;
    }

    $('#camera_modal').show();
    this.startCamera();
    },

    startCamera: function () {
    if (this.isScanning) return;

    const self = this;
    this.html5QrCode
    .start(
    this.config.cameraConfig,
    this.config.scanConfig,
    function (decodedText) {
    self.closeCamera();
    self.processBarcodeForCurrentPage(decodedText);
    if (typeof toastr !== 'undefined') {
    toastr.success('Barcode scanned: ' + decodedText);
    }
    },
    function () {
    $('#status').text(
    '📷 Scanning... Position barcode in view'
    );
    }
    )
    .then(() => {
    self.isScanning = true;
    $('#status').text('📷 Camera active');
    })
    .catch((err) => {
    $('#status').text('❌ Camera error: ' + err);
    setTimeout(() => self.closeCamera(), 2000);
    });
    },

    processBarcodeForCurrentPage: function (barcode) {
    const searchInput = $('#' + this.currentSearchInputId);
    if (searchInput.length) {
    searchInput.val(barcode).trigger('input').trigger('change');
    }
    },

    closeCamera: function () {
    if (this.isScanning && this.html5QrCode) {
    this.html5QrCode.stop().then(() => {
    this.isScanning = false;
    });
    }
    $('#camera_modal').hide();
    },

    closeModalOnly: function () {
    this.closeCamera();
    return false;
    },
    };

    $(document).ready(function () {
    if (
    $('.camera-barcode-scanner-btn').length ||
    $('#barcode_scanner_btn').length
    ) {
    CameraBarcodeScanner.init();
    }
    });
    })(jQuery);

    📜 Step 3: Include Required Scripts
    Update resources/views/layouts/partials/javascripts.blade.php:
    <!-- Camera Barcode Scanner Dependencies --> <script src="https://unpkg.com/[email protected]/html5-qrcode.min.js"></script> <script src="{{ asset('js/camera-barcode-scanner.js') }}"></script> 📑 Step 4: Implementation by Page Type
    🛒 POS System
    File: resources/views/sale_pos/partials/pos_form.blade.php
    Solution 1: Default Settings
    <span class="input-group-btn"> {{-- Camera Barcode Scanner Button --}} <x-camera-barcode-scanner search-input-id="search_product" /> <!-- Other buttons... --> </span> Solution 2: Better Styling
    <span class="input-group-btn"> {{-- Camera Barcode Scanner Button --}} <x-camera-barcode-scanner search-input-id="search_product" button-class="pos-camera-btn" /> <!-- Other buttons... --> </span> 💰 Sales Pages
    Files: resources/views/sell/create.blade.php, resources/views/sell/edit.blade.php
    Solution 1: Default Settings
    <div class="input-group"> <div class="input-group-btn"> <button type="button" class="btn btn-default bg-white btn-flat" data-toggle="modal" data-target="#configure_search_modal"> <i class="fas fa-search-plus"></i> </button> </div> {!! Form::text('search_product', null, ['class' => 'form-control mousetrap', 'id' => 'search_product', 'placeholder' => __('lang_v1.search_product_placeholder')]) !!} <span class="input-group-btn"> <x-camera-barcode-scanner search-input-id="search_product" /> <button type="button" class="btn btn-default bg-white btn-flat pos_add_quick_product"> <i class="fa fa-plus-circle text-primary fa-lg"></i> </button> </span> </div> Solution 2: Better Styling
    <div class="input-group"> <div class="input-group-btn"> <button type="button" class="btn btn-default bg-white btn-flat" data-toggle="modal" data-target="#configure_search_modal" title="{{__('lang_v1.configure_product_search')}}"> <i class="fas fa-search-plus"></i> </button> </div> {!! Form::text('search_product', null, ['class' => 'form-control mousetrap', 'id' => 'search_product', 'placeholder' => __('lang_v1.search_product_placeholder')]) !!} <span class="input-group-btn"> <x-camera-barcode-scanner search-input-id="search_product" button-class="sales-camera-btn" title="Scan Product Barcode" /> <button type="button" class="btn btn-default bg-white btn-flat pos_add_quick_product" data-href="{{action([\App\Http\Controllers\ProductController::class, 'quickAdd'])}}" data-container=".quick_add_product_modal"> <i class="fa fa-plus-circle text-primary fa-lg"></i> </button> </span> </div> 📦 Purchase Pages
    Files: resources/views/purchase/create.blade.php, resources/views/purchase/edit.blade.php
    Solution 1: Default Settings
    <div class="col-sm-2"> <div class="form-group"> <x-camera-barcode-scanner search-input-id="search_product" /> <button tabindex="-1" type="button" class="btn btn-link btn-modal" data-href="{{action([\App\Http\Controllers\ProductController::class, 'quickAdd'])}}" data-container=".quick_add_product_modal"> <i class="fa fa-plus"></i> @lang('product.add_new_product') </button> </div> </div> Solution 2: Better Styling
    <div class="col-sm-2"> <div class="form-group"> <x-camera-barcode-scanner search-input-id="search_product" :show-in-group="false" button-style="link" button-text="📷
    Scan Barcode"
    :full-width="true" />

    <button tabindex="-1" type="button" class="btn btn-link btn-modal" data-href="{{action([\App\Http\Controllers\ProductController::class, 'quickAdd'])}}" data-container=".quick_add_product_modal">
    <i class="fa fa-plus"></i> @lang('product.add_new_product')
    </button>
    </div>
    </div>

    📋 Purchase Order Pages
    Files: resources/views/purchase_order/create.blade.php, resources/views/purchase_order/edit.blade.php
    Solution 1: Default Settings
    <div class="col-sm-2"> <div class="form-group"> <x-camera-barcode-scanner search-input-id="search_product" /> <button tabindex="-1" type="button" class="btn btn-link btn-modal" data-href="{{action([\App\Http\Controllers\ProductController::class, 'quickAdd'])}}" data-container=".quick_add_product_modal"> <i class="fa fa-plus"></i> @lang('product.add_new_product') </button> </div> </div> Solution 2: Better Styling
    <div class="col-sm-2"> <div class="form-group"> <x-camera-barcode-scanner search-input-id="search_product" :show-in-group="false" button-style="link" button-text="🎥
    Scan Product"
    :full-width="true"
    button-class="purchase-order-scanner" />

    <button tabindex="-1" type="button" class="btn btn-link btn-modal" data-href="{{action([\App\Http\Controllers\ProductController::class, 'quickAdd'])}}" data-container=".quick_add_product_modal">
    <i class="fa fa-plus"></i> @lang('product.add_new_product')
    </button>
    </div>
    </div>

    🔄 Stock Transfer Pages
    Files: resources/views/stock_transfer/create.blade.php, resources/views/stock_transfer/edit.blade.php
    Solution 1: Default Settings
    <div class="form-group"> <div class="input-group"> <span class="input-group-addon"> <i class="fa fa-search"></i> </span> {!! Form::text('search_product', null, ['class' => 'form-control', 'id' => 'search_product_for_srock_adjustment', 'placeholder' => __('stock_adjustment.search_product')]) !!} <span class="input-group-btn"> <x-camera-barcode-scanner search-input-id="search_product_for_srock_adjustment" /> </span> </div> </div> Solution 2: Better Styling
    <div class="form-group"> <div class="input-group"> <span class="input-group-addon"> <i class="fa fa-search"></i> </span> {!! Form::text('search_product', null, ['class' => 'form-control', 'id' => 'search_product_for_stock_transfer', 'placeholder' => __('lang_v1.search_product_placeholder')]) !!} <span class="input-group-btn"> <x-camera-barcode-scanner search-input-id="search_product_for_stock_transfer" button-class="transfer-scanner-btn" title="Scan Product for Transfer" /> </span> </div> </div> 📊 Stock Adjustment Pages
    Files: resources/views/stock_adjustment/create.blade.php
    Solution 1: Default Settings
    <div class="form-group"> <div class="input-group"> <span class="input-group-addon"> <i class="fa fa-search"></i> </span> {!! Form::text('search_product', null, ['class' => 'form-control','id' => 'search_product_for_srock_adjustment','placeholder' => __('stock_adjustment.search_product'),'disabled',]) !!} <span class="input-group-btn"> <x-camera-barcode-scanner search-input-id="search_product_for_srock_adjustment" /> </span> </div> </div> Solution 2: Better Styling
    <div class="form-group"> <div class="input-group"> <span class="input-group-addon"> <i class="fa fa-search"></i> </span> {!! Form::text('search_product', null, ['class' => 'form-control','id' => 'search_product_for_stock_adjustment','placeholder' => __('stock_adjustment.search_product'),'disabled',]) !!} <span class="input-group-btn"> <x-camera-barcode-scanner search-input-id="search_product_for_stock_adjustment" button-class="adjustment-scanner-btn" title="Scan Product for Adjustment" /> </span> </div> </div> 🔙 Purchase Return Pages
    Files: resources/views/purchase_return/create.blade.php, resources/views/purchase_return/edit.blade.php
    Solution 1: Default Settings
    <div class="col-sm-2"> <div class="form-group"> <x-camera-barcode-scanner search-input-id="search_product" /> <button tabindex="-1" type="button" class="btn btn-link btn-modal" data-href="{{action([\App\Http\Controllers\ProductController::class, 'quickAdd'])}}" data-container=".quick_add_product_modal"> <i class="fa fa-plus"></i> @lang('product.add_new_product') </button> </div> </div> Solution 2: Better Styling
    <div class="col-sm-2"> <div class="form-group"> <x-camera-barcode-scanner search-input-id="search_product" :show-in-group="false" button-style="link" button-text="📱
    Scan Return"
    :full-width="true"
    button-class="return-scanner-btn" />

    <button tabindex="-1" type="button" class="btn btn-link btn-modal" data-href="{{action([\App\Http\Controllers\ProductController::class, 'quickAdd'])}}" data-container=".quick_add_product_modal">
    <i class="fa fa-plus"></i> @lang('product.add_new_product')
    </button>
    </div>
    </div>

    🎛️ Component Configuration Options
    Props Available
    Prop
    Type
    Default
    Description
    search-input-id
    String
    'search_product'
    ID of the target search input
    button-class
    String
    ''
    Additional CSS classes
    :show-in-group
    Boolean
    true
    Input group button vs standalone
    button-style
    String
    'default'
    Bootstrap button style
    :full-width
    Boolean
    false
    Full width button
    button-text
    String
    'Scan Barcode'
    Text for standalone buttons
    Usage Examples
    Input Group Button (Default)
    <x-camera-barcode-scanner search-input-id="search_product" /> Standalone Link Button
    <x-camera-barcode-scanner search-input-id="search_product" :show-in-group="false" button-style="link" button-text="📷
    Scan Barcode"
    :full-width="true" />

    Success Button with Custom Class
    <x-camera-barcode-scanner search-input-id="search_product" :show-in-group="false" button-style="success" button-text="Scan Product" button-class="my-custom-class" /> 🔧 Troubleshooting
    Common Issues
    Camera Not Working
    Problem: Camera doesn't start
    Solution: Ensure HTTPS connection
    Check: Browser permissions for camera access
    Button Not Responding
    Problem: Click doesn't open camera
    Solution: Check if scripts are loaded properly
    Check: Browser console for JavaScript errors
    Wrong Input Field
    Problem: Barcode goes to wrong input
    Solution: Verify search-input-id matches actual input ID
    Check: Inspect element to confirm input ID
    Debug Mode
    Add this to your page to debug:
    console.log('Scanner available:', CameraBarcodeScanner.isAvailable()); console.log('Current page type:', CameraBarcodeScanner.currentPageType); console.log('Target input:', CameraBarcodeScanner.currentSearchInputId); 🚀 Advanced Features
    Keyboard Shortcut
    Press Ctrl + B to open camera scanner
    Works on any page with scanner buttons
    Mobile Support
    Responsive design for mobile devices
    Touch-friendly buttons
    Mobile camera optimization
    Error Handling
    Graceful fallback when camera unavailable
    Clear error messages for users
    Automatic retry mechanisms
    📈 Performance Tips
    Optimization
    Single Modal: Modal included only once per page with @once
    Event Delegation: Uses event delegation for better performance
    Lazy Loading: Scanner initializes only when needed
    Memory Management: Proper cleanup when camera stops
    Best Practices
    Use specific input IDs for better targeting
    Test on HTTPS environment
    Provide fallback for non-camera devices
    Keep component props simple and focused
    🔒 Security Considerations
    HTTPS Requirement
    Camera access requires HTTPS
    Development: Use localhost or HTTPS
    Production: Ensure SSL certificate
    Permissions
    Browser requests camera permission
    Users can deny and use manual input
    Graceful degradation when permission denied
    📱 Browser Compatibility
    Supported Browsers
    ✅ Chrome 60+
    ✅ Firefox 55+
    ✅ Safari 11+
    ✅ Edge 79+
    ✅ Mobile browsers (iOS Safari, Chrome Mobile)
    Fallback Support
    Automatic fallback to manual input
    Works without camera on any device
    Progressive enhancement approach
    🎉 Conclusion
    You now have a universal camera barcode scanner implemented across all Ultimate POS modules! The component is:
    Reusable across all pages
    Flexible with multiple styling options
    Robust with error handling and fallbacks
    User-friendly with clear instructions
    Mobile-optimized for all devices
    Next Steps
    Test on all target pages
    Customize styling to match your theme
    Add additional features as needed
    Monitor user feedback and iterate
    Happy scanning! 🎯📱
  12. MPWA Perfex WhatsApp Notifications

    Mpwa WhatsApp – Perfex CRM WhatsApp Notifications Integration
    📌 Overview
    Our aim is to deliver maximum functionality while keeping everything user-friendly. With this integration, you can configure the plugin exactly as you need — all within Perfex CRM — to boost communication and security for both your customers and staff.
    By connecting Perfex CRM to Mpwa WhatsApp, you can send real-time notifications directly through WhatsApp, ensuring important updates never go unnoticed.
    🔔 Why Use WhatsApp Notifications in Perfex?
    Improve security and trust by keeping customers updated instantly.
    Keep staff members informed with automated reminders.
    Reduce missed deadlines with smart alerts for invoices, proposals, and contracts.
    Enjoy seamless integration with your existing Perfex dashboard.
    ⚙️ Supported Actions
    Once enabled, the integration automatically handles key workflows:
    Invoices
    Overdue Notice
    Due Date Reminder
    Payment Recorded
    Estimates & Proposals
    Estimate Expiration Reminder
    Proposal Expiration Reminder
    New Comment on Proposal (Customer)
    New Comment on Proposal (Staff)
    Contracts
    Contract Expiration Reminder
    Contract Sign Reminder
    New Comment on Contract (Customer)
    New Comment on Contract (Staff)
    And More Coming Soon
    Additional triggers will be added in future updates of Perfex to keep your communication even more automated.
    🖥️ Example Dashboard
    With the Perfex WhatsApp Notifications Dashboard, you can:
    Manage your API Key and sender number easily.
    Toggle supported triggers on/off.
    Review notification logs for better tracking.
    🚀 Conclusion
    With Mpwa WhatsApp integration for Perfex CRM, you can:
    Strengthen customer relationships.
    Keep staff aligned with timely updates.
    Streamline communication through the world’s most popular messaging app.
    📥 Download
    Ready to get started? Download the MPWA Perfex CRM WhatsApp Notifications Integration here:
    👉 Download Now
  13. Smart School WhatsApp Notification Integration

    Our aim is to provide maximum functionality while maintaining a user-friendly interface, so you can configure the plugin exactly as needed. To get the best results, it’s useful to be familiar with all available options.
    This guide explains how to install and configure the MPWA Smart School WhatsApp Notifications plugin in just a few steps.
    Key Features of MPWA Smart School WhatsApp Notifications
    Direct WhatsApp Delivery — Send notifications instantly via WhatsApp instead of traditional SMS.
    Seamless Integration — Works natively with Smart School’s Custom SMS Gateway option.
    Flexible Configuration — Enter your own API Key and Sender/Phone Number with ease.
    User-Friendly Workflow — Send messages directly from the Communicate → Send SMS panel.
    Cost-Effective — Use WhatsApp as a free or low-cost alternative to SMS.
    Supports Multiple Use Cases:
    Fee reminders
    Attendance alerts
    Exam schedules
    Announcements & updates
    Scalable — Suitable for small schools or large institutions with thousands of recipients.
    Reliable Delivery — Messages are routed through your MPWA account with better tracking.
    Section 1: Installation
    Download the Plugin
    Mpwa-Smart-School.zip
    Unzip into Your Smart School Root
    Extract the downloaded file into the root directory of your Smart School website.
    Enter Your MPWA Credentials
    Open:
    application/libraries/customsms.php Add:
    API Key from your MPWA account (https://mpwa.to)
    Sender/Phone Number with country code but without the “+”
    ✅ Correct: 201234567890
    ❌ Incorrect: +201234567890
    Section 2: Settings (Inside Smart School)
    Log in to your Smart School Admin Panel.
    Navigate to System Settings → SMS Settings.
    Choose Custom SMS Gateway.
    Enter Gateway Name: MPWA.
    Select Enabled → then Save.
    Sending a Test Message (Communicate)
    Go to Communicate → Send SMS.
    Under Send Through, select: SMS (this will now route via MPWA).
    Choose your recipients in the Message to field (class, group, or individuals).
    Type your content in the Message field.
    Click Send Now → then Submit.
    Sample Message Templates
    Fee Reminder
    Dear {name}, your fee for {month} is due on {date}. Please complete payment to avoid a late fee. – {school} Attendance Alert
    Attendance Update: {name} was marked {status} on {date}. – {school} Exam Notification
    Exam Reminder: {subject} exam will be held on {exam_date} at {time}. Venue: {room}. – {school} Quick Checklist
    API Key and Sender Number entered correctly (no “+”).
    Custom SMS Gateway enabled with name MPWA.
    Sent a test message from Communicate → Send SMS.
    Verified delivery to your WhatsApp number.
    Troubleshooting
    Message not delivered?
    Double-check the number format (country code without “+”).
    Confirm your API Key is correct and the gateway is Enabled.
    Try sending plain text (no emojis or line breaks) for the first test.
    Gateway not showing?
    Make sure plugin files are placed in the Smart School root.
    Clear cache/OPcache and reload the admin panel.
    Wrong recipients?
    Double-check your Message to selection before sending.
    Conclusion
    With just a few steps, you can integrate Smart School with MPWA to send reliable WhatsApp notifications to students and parents.
    Download the plugin, add your credentials, enable the gateway, and send your first test message in minutes.
  14. IT infrastructure
    I like to think of infrastructure as everything from wall jack to wall jack. Thinking of infrastructure in this manner enables effective conversations with those who are less familiar with the various components.
    The term IT infrastructure is defined in ITIL v3 as a combined set of hardware, software, networks, facilities, etc. (including all of the information technology related equipment), used to develop, test, deliver, monitor, control or support IT services. Associated people, processes and documentation are not part of IT Infrastructure.
    Switching
    A network switch is the device that provides connectivity between network devices on a Local Area Network (LAN). A switch contains several ports that physically connect to other network devices – including other switches, routers and servers. Early networks used bridges, in which each device “saw” the traffic of all other devices on the network. Switches allow two devices on the network to talk to each other without having to forward that traffic to all devices on the network.
    Routers
    Routers move packets between networks. Routing allows devices separated on different LAN’s to talk to each other by determining the next “hop” that will allow the network packet to eventually get to its destination.
    If you have ever manually configured your IP address on a workstation, the default gateway value that you keyed in was the IP address of your router.
    Firewalls
    Firewalls are security devices at the edge of the network. The firewall can be thought of as the guardian or gatekeeper.
    A set of rules defines what types of network traffic will be allowed through the firewall and what will be blocked.
    In the simplest version of a firewall, rules can be created which allow a specific port and /or protocol for traffic from one device (or a group of devices) to a device or group of devices. For example: if you want to host your own web server and only limit it to only web traffic, you would typically have two firewall rules that look something like this:
    Source Destination Port / Protocol Description any 10.1.1.100 80 / http Web traffic in any 10.1.1.100 443/ https Secure web traffic in The source is the originating device. In this case, any means ‘allow any computer to communicate’. Destination is the specific IP address of your internal web server. Port/Protocol defines what type of traffic is allowed from the source to the destination. Most firewall devices allow for a description for each rule that have no effect on the rule itself. It is used only for notes.
    Firewall devices can get complicated quickly. There are many different types of firewalls which approach managing traffic in different ways. Detailed firewall capabilities and methods are beyond the scope of this post.
    Servers
    A network server is simply just another computer but usually larger in terms of resources than what most people think of. A server allows multiple users to access and share its resources. There are several types of servers.
    Perhaps the most common type of server is a file server. A file server provides end users with a centralized location to store files. When configured correctly, file servers can allow or prevent specific users to access files. Another common type of server is a directory server. A directory server provides a central database of user accounts that can be used by several computers. This allows centralized management of user accounts which are used to access server resources. Perhaps the most common type of server is a web server. Web servers use HTTP (Hyper Text Transfer Protocol) to provide files to users through a web browser. There are also application servers, database servers, print servers, etc. Physical plant
    The physical plant is all of the network cabling in your office buildings and server room/ datacenter. This all too often neglected part of your infrastructure usually is the weakest link and is the cause of most system outages when not managed properly. There are two main types of cabling in the infrastructure -CAT 5/6/7 and fiber optic. Each type of cabling has several different subtypes depending on the speed and distance required to connect devices.
    People
    By the strict ITIL definition, people are not considered part of the network infrastructure. However, without competent well-qualified people in charge of running and maintaining your infrastructure, you will artificially limit the capabilities of your organization. In larger organizations, there are specialty positions for each of the areas mentioned in this article. In smaller organizations, you will find that the general systems administrator handles many of the roles.
    Server rooms / Data center
    The server room, or data center (in large organizations), can be thought of as the central core of your network. It is the location in which you place all of your servers and usually acts as the center of most networks.
    Infrastructure Software
    This is perhaps the most “gray” of all infrastructure components. However, I consider server operating systems and directory services (like MS Active Directory) to be part of the infrastructure. Without multi-user operating systems, the hardware can’t perform its infrastructure functions.

Account

Navigation

Search

Search

Configure browser push notifications

Chrome (Android)
  1. Tap the lock icon next to the address bar.
  2. Tap Permissions → Notifications.
  3. Adjust your preference.
Chrome (Desktop)
  1. Click the padlock icon in the address bar.
  2. Select Site settings.
  3. Find Notifications and adjust your preference.