= 2; $can_delete = lookupUserPermission('module_sales') >= 3 && !empty($config_destructive_deletes_enable); // Tabs $views = [ 'all' => ['label' => 'All', 'icon' => 'fa-th-list', 'sql' => ''], 'service' => ['label' => 'Services', 'icon' => 'fa-wrench', 'sql' => "AND product_type = 'service'"], 'product' => ['label' => 'Products', 'icon' => 'fa-box-open', 'sql' => "AND product_type = 'product'"], 'hardware' => ['label' => 'Hardware', 'icon' => 'fa-laptop', 'sql' => "AND " . productHardwareSql(), 'title' => 'Becomes a client asset when it is sold'], 'recurring' => ['label' => 'Recurring', 'icon' => 'fa-redo-alt', 'sql' => "AND product_frequency IS NOT NULL", 'title' => 'Billed each month, quarter or year by default'], 'stocked' => ['label' => 'Stocked', 'icon' => 'fa-warehouse', 'sql' => "AND product_track_stock = 1", 'title' => 'Stock tracked'], 'reorder' => ['label' => 'Reorder', 'icon' => 'fa-shopping-basket', 'sql' => "AND " . productReorderSql(), 'title' => 'Stock at or under its reorder level, or out'], ]; $view = (string) ($_GET['type'] ?? 'all'); if (!isset($views[$view])) { $view = 'all'; } // Category Filter $category_filter = intval($_GET['category'] ?? 0); $category_query = $category_filter ? "AND product_category_id = $category_filter" : ''; // Vendor Filter $vendor_filter = intval($_GET['vendor'] ?? 0); $vendor_query = $vendor_filter ? "AND product_vendor_id = $vendor_filter" : ''; $search_query = "AND (product_name LIKE '%$q%' OR product_description LIKE '%$q%' OR product_code LIKE '%$q%' OR product_vendor_part LIKE '%$q%' OR product_location LIKE '%$q%' OR category_name LIKE '%$q%' OR vendor_name LIKE '%$q%' OR product_price LIKE '%$q%')"; $from = "FROM products LEFT JOIN categories ON product_category_id = category_id LEFT JOIN taxes ON product_tax_id = tax_id LEFT JOIN vendors ON product_vendor_id = vendor_id"; $where = "WHERE product_$archive_query $search_query $category_query $vendor_query"; // Only these sort - an unknown column takes the page down $sortable_columns = ['product_name', 'product_code', 'category_name', 'vendor_name', 'product_price', 'product_cost', 'margin', 'product_stock', 'product_frequency', 'product_created_at']; if (!in_array($sort, $sortable_columns, true)) { $sort = 'product_name'; } $sort_sql = $sort === 'margin' ? "IF(product_cost > 0 AND product_price > 0, (product_price - product_cost) / product_price, NULL)" : $sort; $sql = mysqli_query( $mysqli, "SELECT SQL_CALC_FOUND_ROWS products.*, category_name, category_color, tax_name, tax_percent, vendor_name, " . productStockSql() . " AS product_stock $from $where {$views[$view]['sql']} ORDER BY $sort_sql $order, product_name ASC LIMIT $record_from, $record_to" ); $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); $products = []; while ($row = mysqli_fetch_assoc($sql)) { $products[] = $row; } // Each tab's count, with the same search and filters $counts = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(*) AS `all`, COALESCE(SUM(product_type = 'service'), 0) AS service, COALESCE(SUM(product_type = 'product'), 0) AS product, COALESCE(SUM(product_frequency IS NOT NULL), 0) AS recurring, COALESCE(SUM(product_track_stock = 1), 0) AS stocked, COALESCE(SUM(" . productReorderSql() . "), 0) AS reorder, COALESCE(SUM(" . productHardwareSql() . "), 0) AS hardware $from $where")); // The catalog at work - recurring, sold, on the shelf $scope_invoice = clientScopeSql('invoice_client_id'); $scope_recurring = clientScopeSql('recurring_invoice_client_id'); $recurring_stat = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COALESCE(SUM(" . recurringInvoiceMonthlySql('item_subtotal', 'recurring_invoice_frequency') . "), 0) AS monthly, COUNT(DISTINCT item_product_id) AS products, COUNT(DISTINCT recurring_invoice_client_id) AS clients FROM recurring_invoice_items JOIN recurring_invoices ON recurring_invoice_id = item_recurring_invoice_id WHERE item_product_id > 0 AND recurring_invoice_status = 1 AND recurring_invoice_archived_at IS NULL $scope_recurring")); $sold_stat = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COALESCE(SUM(item_subtotal), 0) AS amount, COUNT(DISTINCT item_invoice_id) AS invoices FROM invoice_items JOIN invoices ON invoice_id = item_invoice_id WHERE item_product_id > 0 AND invoice_status NOT IN ('Draft', 'Cancelled', 'Non-Billable') AND invoice_date >= CURDATE() - INTERVAL 90 DAY $scope_invoice")); $top_seller = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT product_id, product_name, SUM(item_subtotal) AS amount FROM invoice_items JOIN invoices ON invoice_id = item_invoice_id JOIN products ON product_id = item_product_id WHERE invoice_status NOT IN ('Draft', 'Cancelled', 'Non-Billable') AND invoice_date >= CURDATE() - INTERVAL 90 DAY $scope_invoice GROUP BY product_id ORDER BY amount DESC LIMIT 1")); $stock_stat = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COALESCE(SUM(GREATEST(s.on_hand, 0) * product_cost), 0) AS value, COALESCE(SUM(GREATEST(s.on_hand, 0)), 0) AS units, COUNT(*) AS tracked, COALESCE(SUM(s.on_hand <= 0), 0) AS out_of_stock, COALESCE(SUM(product_cost <= 0 AND s.on_hand > 0), 0) AS no_cost FROM products JOIN (SELECT stock_product_id, SUM(stock_qty) AS on_hand FROM product_stock GROUP BY stock_product_id) s ON s.stock_product_id = product_id WHERE product_track_stock = 1 AND product_archived_at IS NULL")); $tracked_count = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM products WHERE product_track_stock = 1 AND product_archived_at IS NULL"))[0]); $reorder_count = productReorderCount(); // Lines on recurring invoices, drafts and quotes still at an old price $reprice = productRepriceCount(); $catalog_count = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM products"))[0]); $filter_url = function ($changes) { $params = array_merge($_GET, $changes); unset($params['page']); return '?' . http_build_query(array_filter($params, function ($v) { return $v !== '' && $v !== null && $v !== 0; })); }; $money = function ($amount, $currency = null) use ($session_company_currency) { return escapeHtml(billingMoney($amount, $currency ?: $session_company_currency)); }; $add_type = in_array($view, ['product', 'hardware', 'stocked', 'reorder'], true) ? 'product' : 'service'; ?>
Catalog
services · products
billed each month, quarter or year
On recurring
/mo
item across client
Sold, 90 days
Top: On invoices
On the shelf
unit
On product - add a cost to value them · to reorder
unit on product · to reorder
-
No product tracks its stock
line at an old price on recurring invoices, drafts or quotes, from product 0 ? ' more' : ' less') . ' a month' : '' ?>.
Update Prices

Products and Services

Archived
' . escapeHtml($row['product_code']) . '' : '', escapeHtml((string) $row['category_name']), $row_type === 'product' && $row['product_asset_type'] ? '' . escapeHtml($row['product_asset_type']) . '' : '']); ?>
Item Billed Stock Vendor Price Margin
Archived
One time in stock
Reorder 0 ? ' ' . escapeHtml(billingQuantity($row['product_reorder_qty'])) : '' ?>
As needed -
-
0) { ?>
cost
-' ?>
Nothing in the catalog yet
Add the services and products you sell, and pick them as lines on invoices, quotes and recurring invoices.
Or load a starter catalog from Starter Content.
Nothing here.
'products and services', 'icon' => 'fa-box-open', 'views' => ['type'], 'view' => emptyStatePill($views, $view, 'all', ['reorder']), 'text' => 'What you sell - your services and the products you resell - with their prices, ready to add to an invoice.', 'actions' => $can_edit ? [ ['label' => 'New Service', 'modal' => 'modals/product/product_add.php?type=service', 'size' => 'lg', 'icon' => 'fa-wrench'], ['label' => 'New Product', 'modal' => 'modals/product/product_add.php?type=product', 'size' => 'lg', 'icon' => 'fa-box-open'], ] : [], ]; ?>