Files
itflow/agent/invoices.php
johnnyq 2311d1f9d8 Speed up Admin Audit Logs, Sign-ins and Users, Transactions, Income, Invoices, Projects and the invoice page on installs with years of history: database 3.6.6 adds logs (log_user_id, log_type, log_action), logs (log_type, log_action, log_user_id, log_ip, log_created_at) and tickets (ticket_invoice_id), each only where missing and in db.sql, leaving out one it cannot add with a warning in App Logs; Audit Logs drops SQL_CALC_FOUND_ROWS and counts its views with one GROUP BY log_type, log_action in the new auditLogViewCounts, which also hands back the types and actions in the view so auditLogKindsSql finds a sparse view or action on the index, and builds its who, client, type and action lists with auditLogDistinctSql in place of EXISTS and DISTINCT over the log, as the export window now does; Sign-ins reads only the new log_type index, counts unknown emails for the page's addresses in their own query and ends every sort on log_ip or log_user_id so tied rows keep one order; the new listNewestFirst in functions/app.php pages a newest-first list from the last month, year and four years back before the whole of it, used by Transactions without the running balance and by Income, which both take their count from their pill in place of SQL_CALC_FOUND_ROWS, with incomeUnionSql(false) leaving out the name joins for Income's counts when there is no search; Invoices replaces the LEFT JOIN of every payment grouped by invoice with a paid lookup for the page's invoices and the unpaid ones in the totals; the project lookups, through the new PROJECT_TICKETS_SQL, the client onboarding ticket counts and the expense refund lookups also say the column is > 0, so the database stays on the index where nearly every row has 0 and its statistics say a lookup finds every row, Projects having read 33.6 million rows with no ticket on a project; checked on an eight-year test install against the old code over 103 filter, sort and page combinations and a crawl of all 233 pages; add the changes to the 26.11.8 changelog section
2026-11-13 12:58:04 -05:00

477 lines
32 KiB
PHP

<?php
/*
* Invoices - one client's, or every invoice the agent can see.
*
* Along the top, the money: what is owed, what of that is late, what is
* sitting in drafts, and what came in over the last 30 days - each opens
* the invoices behind it. The pills filter by where an invoice stands. Each
* row has what is still owed on it and, while it is owed, how long until
* it is due or how late it is. ?status= takes Draft, Unpaid, Overdue,
* Paid and Cancelled - the dashboard, client pages and reports link with
* it. ?recurring_invoice_id= shows the invoices a recurring invoice made.
*/
// Default Column Sortby/Order Filter
$sort = "invoice_number";
$order = "DESC";
// If client_id is in URI then show client Side Bar and client header
if (isset($_GET['client_id'])) {
require_once "includes/inc_all_client.php";
$client_query = "AND invoice_client_id = $client_id";
$client_url = "client_id=$client_id&";
} else {
require_once "includes/inc_all.php";
$client_query = '';
$client_url = '';
}
// Perms
enforceUserPermission('module_sales');
$can_edit = lookupUserPermission('module_sales') >= 2;
$can_delete = lookupUserPermission('module_sales') >= 3;
// Taking payments and sending statements are accounting's too (billingMoneyCan())
$can_pay = billingMoneyCan('take');
$can_statement = billingMoneyCan('statement');
$mail_ready = !empty($config_smtp_provider);
// Client Filter (all clients)
$client = '';
if (!$client_url && !empty($_GET['client'])) {
$client = intval($_GET['client']);
$client_query = "AND invoice_client_id = $client";
}
// From a recurring invoice
$recurring_filter = intval($_GET['recurring_invoice_id'] ?? 0);
$recurring_query = $recurring_filter ? "AND invoice_recurring_invoice_id = $recurring_filter" : '';
$recurring_filter_row = $recurring_filter ? billingRow('recurring', $recurring_filter) : null;
// Hardware sold on them that isn't on the client's assets yet (functions/products.php)
$hardware_filter = !empty($_GET['hardware']);
$hardware_query = $hardware_filter ? "AND " . productHardwareInvoiceSql() . " AND invoices.invoice_id IN (SELECT invoice_items.item_invoice_id FROM invoice_items
JOIN products ON products.product_id = invoice_items.item_product_id " . productHardwareMadeJoinSql() . " WHERE " . productHardwareShortSql() . ")" : '';
// Category Filter
$category_filter = intval($_GET['category'] ?? 0);
$category_query = $category_filter ? "AND invoice_category_id = $category_filter" : '';
$open_sql = "invoice_status IN ('Sent', 'Viewed', 'Partial')";
$statuses = [
'' => ['label' => 'All', 'where' => ''],
'Draft' => ['label' => 'Draft', 'icon' => 'fa-pencil-ruler', 'where' => "invoice_status = 'Draft'", 'title' => 'Not sent yet - the client can\'t see them'],
'Unpaid' => ['label' => 'Unpaid', 'icon' => 'fa-hand-holding-usd', 'where' => $open_sql, 'title' => 'Sent, and not paid in full'],
'Overdue' => ['label' => 'Overdue', 'icon' => 'fa-exclamation-circle', 'where' => "$open_sql AND invoice_due < CURDATE()", 'title' => 'Past their due date and not paid in full'],
'Paid' => ['label' => 'Paid', 'icon' => 'fa-check-circle', 'where' => "invoice_status = 'Paid'"],
'Cancelled' => ['label' => 'Cancelled', 'icon' => 'fa-ban', 'where' => "invoice_status IN ('Cancelled', 'Non-Billable')", 'title' => 'Cancelled, or closed off as non-billable'],
];
$status = isset($statuses[$_GET['status'] ?? '']) ? ($_GET['status'] ?? '') : '';
$status_query = $statuses[$status]['where'] ? 'AND ' . $statuses[$status]['where'] : '';
// Only these sort - filter_header.php hands through any lowercase word
$sortable_columns = ['invoice_number' => 'invoice_number', 'invoice_scope' => 'invoice_scope', 'client_name' => 'client_name', 'invoice_date' => 'invoice_date',
'invoice_due' => 'invoice_due', 'invoice_amount' => 'invoice_amount', 'balance' => 'balance', 'invoice_status' => 'invoice_status', 'category_name' => 'category_name'];
if (!isset($sortable_columns[$sort])) {
$sort = 'invoice_number';
}
// What an invoice has been paid - looked up for the invoices that need it (the page's, and the
// unpaid ones for the money along the top) where a LEFT JOIN to every payment grouped by invoice
// added up all of them on every load
$paid_sql = "(SELECT COALESCE(SUM(payment_amount), 0) FROM payments WHERE payment_invoice_id = invoices.invoice_id AND payment_invoice_id > 0)";
$scope_query = clientScopeSql('invoice_client_id');
$search_query = "AND (CONCAT(invoice_prefix, invoice_number) LIKE '%$q%' OR invoice_scope LIKE '%$q%' OR client_name LIKE '%$q%' OR invoice_status LIKE '%$q%' OR invoice_amount LIKE '%$q%' OR category_name LIKE '%$q%')";
$date_query = "AND DATE(invoice_date) BETWEEN '$dtf' AND '$dtt'";
$filters = "$scope_query $client_query $recurring_query $hardware_query $category_query $date_query $search_query";
// How many of each, for the pills - what the filters above leave, before the pill
$count_sql = [];
foreach ($statuses as $key => $s) {
$count_sql[] = $key === '' ? 'COUNT(*)' : "COALESCE(SUM({$s['where']}), 0)";
}
$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT " . implode(', ', $count_sql) . " FROM invoices
LEFT JOIN clients ON invoice_client_id = client_id LEFT JOIN categories ON invoice_category_id = category_id WHERE 1 $filters"));
$counts = [];
foreach (array_keys($statuses) as $i => $key) {
$counts[$key] = intval($row[$i]);
}
// The page - its count is its pill's (SQL_CALC_FOUND_ROWS counted the same rows again)
$num_rows = [$counts[$status]];
$sql = mysqli_query($mysqli, "SELECT invoices.*, client_name, category_name, category_color,
recurring_invoice_prefix, recurring_invoice_number,
$paid_sql AS paid, invoice_amount - $paid_sql AS balance
FROM invoices
LEFT JOIN clients ON invoice_client_id = client_id
LEFT JOIN categories ON invoice_category_id = category_id
LEFT JOIN recurring_invoices ON invoice_recurring_invoice_id = recurring_invoice_id
WHERE 1 $filters $status_query
ORDER BY {$sortable_columns[$sort]} $order, invoice_id DESC LIMIT $record_from, $record_to");
$invoices = [];
while ($row = mysqli_fetch_assoc($sql)) {
$invoices[] = $row;
}
// The money along the top - everything in this scope, not narrowed by search, date or pill
$money_scope = "$scope_query $client_query $recurring_query";
$money = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT
COALESCE(SUM(CASE WHEN $open_sql AND balance > 0 THEN balance END), 0) AS owed,
COALESCE(SUM(CASE WHEN $open_sql AND balance > 0 THEN 1 END), 0) AS owed_count,
COALESCE(SUM(CASE WHEN $open_sql AND balance > 0 AND invoice_due < CURDATE() THEN balance END), 0) AS overdue,
COALESCE(SUM(CASE WHEN $open_sql AND balance > 0 AND invoice_due < CURDATE() THEN 1 END), 0) AS overdue_count,
COALESCE(SUM(CASE WHEN invoice_status = 'Draft' THEN invoice_amount END), 0) AS draft,
COALESCE(SUM(CASE WHEN invoice_status = 'Draft' THEN 1 END), 0) AS draft_count
FROM (SELECT invoice_status, invoice_due, invoice_amount, invoice_amount - $paid_sql AS balance
FROM invoices WHERE 1 $money_scope) AS scoped"));
$collected = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COALESCE(SUM(payment_amount), 0) AS amount, COUNT(*) AS payments FROM payments
JOIN invoices ON payment_invoice_id = invoice_id
WHERE payment_date >= CURDATE() - INTERVAL 30 DAY $money_scope"));
$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; }));
};
// Clients with a card on file, for Charge Saved Card - one query, not one a row
$saved_card_clients = [];
$sql_saved = mysqli_query($mysqli, "SELECT DISTINCT saved_payment_client_id FROM client_saved_payment_methods
JOIN payment_providers ON saved_payment_provider_id = payment_provider_id WHERE payment_provider_active = 1");
while ($row = mysqli_fetch_row($sql_saved)) {
$saved_card_clients[intval($row[0])] = true;
}
$stat_money = function ($amount) use ($session_company_currency) {
return escapeHtml(billingMoney($amount, $session_company_currency));
};
?>
<!-- The money -->
<div class="row g-3 mb-3">
<div class="col-sm-6 col-xl-3">
<a class="itflow-billing-stat <?= $status === 'Unpaid' ? 'active' : '' ?>" href="<?= escapeHtml($filter_url(['status' => 'Unpaid'])) ?>">
<div class="itflow-billing-stat-label"><i class="fas fa-hand-holding-usd me-1"></i>Owed to you</div>
<div class="itflow-billing-stat-value"><?= $stat_money($money['owed']) ?></div>
<div class="itflow-billing-stat-note"><?= intval($money['owed_count']) ?> unpaid invoice<?= intval($money['owed_count']) === 1 ? '' : 's' ?></div>
</a>
</div>
<div class="col-sm-6 col-xl-3">
<a class="itflow-billing-stat <?= $status === 'Overdue' ? 'active' : '' ?>" href="<?= escapeHtml($filter_url(['status' => 'Overdue'])) ?>">
<div class="itflow-billing-stat-label"><i class="fas fa-exclamation-circle me-1"></i>Overdue</div>
<div class="itflow-billing-stat-value <?= $money['overdue'] > 0 ? 'text-danger-emphasis' : '' ?>"><?= $stat_money($money['overdue']) ?></div>
<div class="itflow-billing-stat-note"><?= intval($money['overdue_count']) ?> past due<?= $money['owed'] > 0 ? ' &middot; ' . round($money['overdue'] / $money['owed'] * 100) . '% of what is owed' : '' ?></div>
</a>
</div>
<div class="col-sm-6 col-xl-3">
<a class="itflow-billing-stat <?= $status === 'Draft' ? 'active' : '' ?>" href="<?= escapeHtml($filter_url(['status' => 'Draft'])) ?>">
<div class="itflow-billing-stat-label"><i class="fas fa-pencil-ruler me-1"></i>In drafts</div>
<div class="itflow-billing-stat-value"><?= $stat_money($money['draft']) ?></div>
<div class="itflow-billing-stat-note"><?= intval($money['draft_count']) ?> not sent yet</div>
</a>
</div>
<div class="col-sm-6 col-xl-3">
<a class="itflow-billing-stat <?= $status === 'Paid' ? 'active' : '' ?>" href="<?= escapeHtml($filter_url(['status' => 'Paid'])) ?>">
<div class="itflow-billing-stat-label"><i class="fas fa-coins me-1"></i>Collected, 30 days</div>
<div class="itflow-billing-stat-value text-success-emphasis"><?= $stat_money($collected['amount']) ?></div>
<div class="itflow-billing-stat-note"><?= intval($collected['payments']) ?> payment<?= intval($collected['payments']) === 1 ? '' : 's' ?></div>
</a>
</div>
</div>
<div class="card">
<div class="card-header bg-dark py-2">
<h3 class="card-title mt-2"><i class="fa fa-fw fa-file-invoice-dollar me-2"></i>Invoices</h3>
<div class="card-tools">
<div class="btn-group">
<?php if ($can_edit) { ?>
<button type="button" class="btn btn-primary ajax-modal" data-modal-url="modals/invoice/invoice_add.php?<?= $client_url ?>">
<i class="fas fa-plus me-2"></i>New Invoice
</button>
<?php } ?>
<button type="button" class="btn btn-primary dropdown-toggle <?= $can_edit ? 'dropdown-toggle-split' : '' ?>" data-bs-toggle="dropdown"><?= $can_edit ? '' : '<i class="fas fa-ellipsis-h"></i>' ?></button>
<div class="dropdown-menu dropdown-menu-end">
<a class="dropdown-item ajax-modal" href="#"
data-modal-url="<?= buildExportModalUrl('modals/invoice/invoice_export.php', ['client_id', 'status', 'category', 'q'], ['dtf' => $dtf, 'dtt' => $dtt, 'client_id' => $client ?: '']) ?>">
<i class="fa fa-fw fa-download text-secondary me-2"></i>Export
</a>
<?php if ($client_url && $can_statement && $mail_ready) { ?>
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/client/client_statement.php?client_id=<?= $client_id ?>">
<i class="fa fa-fw fa-file-alt text-secondary me-2"></i>Send Statement
</a>
<?php } ?>
<?php if ($client_url) { ?>
<a class="dropdown-item" href="post.php?export_client_statement_pdf=<?= $client_id ?>&amp;csrf_token=<?= $_SESSION['csrf_token'] ?>" target="_blank">
<i class="fa fa-fw fa-file-pdf text-secondary me-2"></i>Statement PDF
</a>
<?php } ?>
<?php if ($client_url && $can_pay && $money['owed'] > 0) { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/payment/payment_bulk_add.php?client_id=<?= $client_id ?>">
<i class="fa fa-fw fa-layer-group text-secondary me-2"></i>Batch Payment
</a>
<?php } ?>
</div>
</div>
</div>
</div>
<div class="card-header py-3">
<form autocomplete="off">
<?php if ($client_url) { ?>
<input type="hidden" name="client_id" value="<?= $client_id ?>">
<?php } ?>
<input type="hidden" name="status" value="<?= escapeHtml($status) ?>">
<?php if ($recurring_filter) { ?>
<input type="hidden" name="recurring_invoice_id" value="<?= $recurring_filter ?>">
<?php } ?>
<?php if ($hardware_filter) { ?>
<input type="hidden" name="hardware" value="1">
<?php } ?>
<div class="row g-2 align-items-center">
<div class="<?= $client_url ? 'col-lg-4' : 'col-lg-3' ?> col-md-6">
<div class="input-group">
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) { echo stripslashes(escapeHtml($q)); } ?>" placeholder="Search number, scope, client, amount...">
<button class="btn btn-dark" title="Search"><i class="fa fa-search"></i></button>
</div>
</div>
<div class="col-lg-2 col-md-6">
<input type="text" id="dateFilter" class="form-control" autocomplete="off" title="Invoice date" aria-label="Invoice date">
<input type="hidden" name="canned_date" id="canned_date" value="<?= escapeHtml($_GET['canned_date'] ?? '') ?>">
<input type="hidden" name="dtf" id="dtf" value="<?= escapeHtml($dtf ?? '') ?>">
<input type="hidden" name="dtt" id="dtt" value="<?= escapeHtml($dtt ?? '') ?>">
</div>
<div class="col-lg-3 col-md-6">
<select class="form-select select2" name="category" onchange="this.form.submit()">
<option value="">- Any category -</option>
<?= categoryFilterOptionsHtml('Income', $category_filter, "SELECT DISTINCT invoice_category_id FROM invoices") ?>
</select>
</div>
<?php if (!$client_url) { ?>
<div class="col-lg-2 col-md-6">
<select class="form-select select2" name="client" onchange="this.form.submit()">
<option value="">- All Clients -</option>
<?php
$sql_clients_filter = mysqli_query($mysqli, "SELECT DISTINCT client_id, client_name, client_abbreviation FROM clients
JOIN invoices ON invoice_client_id = client_id
WHERE 1 " . clientScopeSql('clients.client_id') . " ORDER BY client_name ASC");
while ($row = mysqli_fetch_assoc($sql_clients_filter)) {
$client_option_id = intval($row['client_id']); ?>
<option <?= $client === $client_option_id ? 'selected' : '' ?> value="<?= $client_option_id ?>"<?= selectOptionClient($row['client_id'], $row['client_name'], $row['client_abbreviation'] ?? '') ?>><?= escapeHtml($row['client_name']) ?></option>
<?php } ?>
</select>
</div>
<?php } ?>
<div class="<?= $client_url ? 'col-lg-3' : 'col-lg-2' ?> col-md-6">
<div class="d-flex justify-content-end gap-2">
<?php if ($can_edit) { ?>
<div class="dropdown" id="bulkActionButton" hidden>
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="fas fa-fw fa-layer-group me-2"></i>Bulk Action (<span id="selectedCount">0</span>)
</button>
<div class="dropdown-menu dropdown-menu-end">
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/invoice/invoice_bulk_edit_category.php" data-bulk="true">
<i class="fas fa-fw fa-tag me-2"></i>Set Category
</a>
</div>
</div>
<?php } ?>
</div>
</div>
</div>
</form>
<!-- Where they stand - counted over what the filters above leave -->
<nav class="nav nav-pills itflow-asset-tabs mt-2 flex-nowrap overflow-auto">
<?php foreach ($statuses as $key => $s) {
$alarm = $key === 'Overdue' && $counts[$key]; ?>
<a class="nav-link text-nowrap <?= $status === $key ? 'active' : '' ?>" href="<?= escapeHtml($filter_url(['status' => $key])) ?>"<?= !empty($s['title']) ? ' title="' . escapeHtml($s['title']) . '"' : '' ?>>
<?php if (!empty($s['icon'])) { ?><i class="fas fa-fw <?= $s['icon'] ?> me-1"></i><?php } ?><?= $s['label'] ?>
<span class="badge rounded-pill ms-1 <?= $alarm ? 'text-bg-danger' : '' ?>"><?= $counts[$key] ?></span>
</a>
<?php } ?>
</nav>
<?php if ($recurring_filter) { ?>
<div class="small text-body-secondary mt-2">
<i class="fas fa-filter me-1"></i>Made by <?= $recurring_filter_row ? '<a href="recurring_invoice.php?recurring_invoice_id=' . $recurring_filter . '">' . escapeHtml(billingNumber('recurring', $recurring_filter_row)) . '</a>' : 'a recurring invoice' ?>
<a class="ms-2" href="<?= escapeHtml($filter_url(['recurring_invoice_id' => ''])) ?>">Clear</a>
</div>
<?php } ?>
<?php if ($hardware_filter) { ?>
<div class="small text-body-secondary mt-2">
<i class="fas fa-filter me-1"></i>Hardware sold on them isn't on the client's assets yet - open one and use Add to Assets
<a class="ms-2" href="<?= escapeHtml($filter_url(['hardware' => ''])) ?>">Clear</a>
</div>
<?php } ?>
</div>
<form id="bulkActions" action="post.php" method="post">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 itflow-billing-table">
<thead class="<?= $num_rows[0] ? '' : 'd-none' ?> text-nowrap table-light">
<tr>
<?php if ($can_edit) { ?>
<th class="checkbox-column border-end">
<div class="form-check">
<input class="form-check-input" id="selectAllCheckbox" type="checkbox" onclick="checkAll(this)" title="Select all on this page">
</div>
</th>
<?php } ?>
<th><a class="text-reset text-decoration-none" href="?<?= $url_query_strings_sort ?>&sort=invoice_number&order=<?= $disp ?>">Invoice <?php if ($sort == 'invoice_number') { echo $order_icon; } ?></a></th>
<?php if (!$client_url) { ?>
<th><a class="text-reset text-decoration-none" href="?<?= $url_query_strings_sort ?>&sort=client_name&order=<?= $disp ?>">Client <?php if ($sort == 'client_name') { echo $order_icon; } ?></a></th>
<?php } ?>
<th><a class="text-reset text-decoration-none" href="?<?= $url_query_strings_sort ?>&sort=invoice_date&order=<?= $disp ?>">Date <?php if ($sort == 'invoice_date') { echo $order_icon; } ?></a></th>
<th><a class="text-reset text-decoration-none" href="?<?= $url_query_strings_sort ?>&sort=invoice_due&order=<?= $disp ?>">Due <?php if ($sort == 'invoice_due') { echo $order_icon; } ?></a></th>
<th class="text-end"><a class="text-reset text-decoration-none" href="?<?= $url_query_strings_sort ?>&sort=invoice_amount&order=<?= $disp ?>">Total <?php if ($sort == 'invoice_amount') { echo $order_icon; } ?></a></th>
<th class="text-end"><a class="text-reset text-decoration-none" href="?<?= $url_query_strings_sort ?>&sort=balance&order=<?= $disp ?>">Balance <?php if ($sort == 'balance') { echo $order_icon; } ?></a></th>
<th><a class="text-reset text-decoration-none" href="?<?= $url_query_strings_sort ?>&sort=invoice_status&order=<?= $disp ?>">Status <?php if ($sort == 'invoice_status') { echo $order_icon; } ?></a></th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($invoices as $row) {
$invoice_id = intval($row['invoice_id']);
$row_client_id = intval($row['invoice_client_id']);
$row_number = billingNumber('invoice', $row);
$row_status = (string) $row['invoice_status'];
$row_state = invoiceState($row, $row['paid']);
$row_currency = (string) $row['invoice_currency_code'];
$row_url = "invoice.php?{$client_url}invoice_id=$invoice_id";
$row_can_pay = $can_pay && invoiceCanPay($row, $row_state);
?>
<tr>
<?php if ($can_edit) { ?>
<td class="checkbox-column bg-light border-end">
<div class="form-check">
<input class="form-check-input bulk-select" type="checkbox" name="invoice_ids[]" value="<?= $invoice_id ?>" aria-label="Select <?= escapeHtml($row_number) ?>">
</div>
</td>
<?php } ?>
<td class="itflow-billing-number">
<a class="fw-semibold" href="<?= $row_url ?>"><?= escapeHtml($row_number) ?></a>
<?php if (intval($row['invoice_recurring_invoice_id'])) { ?>
<a class="text-body-secondary small ms-1" href="recurring_invoice.php?recurring_invoice_id=<?= intval($row['invoice_recurring_invoice_id']) ?>" title="Made by <?= escapeHtml($row['recurring_invoice_prefix'] . $row['recurring_invoice_number']) ?>"><i class="fas fa-redo-alt"></i></a>
<?php } ?>
<div class="small text-body-secondary text-truncate" style="max-width: 22rem;">
<?= escapeHtml(implode(' · ', array_filter([$row['invoice_scope'], $row['category_name']]))) ?: '&nbsp;' ?>
</div>
</td>
<?php if (!$client_url) { ?>
<td><a class="text-reset" href="invoices.php?client_id=<?= $row_client_id ?>"><?= escapeHtml($row['client_name']) ?></a></td>
<?php } ?>
<td class="text-nowrap"><?= escapeHtml($row['invoice_date']) ?></td>
<td class="text-nowrap">
<?= escapeHtml($row['invoice_due']) ?>
<?php $due_pill = invoiceDuePill($row_state); if ($due_pill) { ?><div><?= $due_pill ?></div><?php } ?>
</td>
<td class="text-end itflow-money"><?= escapeHtml(billingMoney($row['invoice_amount'], $row_currency)) ?></td>
<td class="text-end itflow-money">
<?php if (in_array($row_state['state'], ['open', 'overdue'], true)) { ?>
<span class="fw-semibold <?= $row_state['state'] === 'overdue' ? 'text-danger-emphasis' : '' ?>"><?= escapeHtml(billingMoney($row_state['balance'], $row_currency)) ?></span>
<?php if ($row_state['paid'] > 0) { ?><div class="small text-body-secondary"><?= escapeHtml(billingMoney($row_state['paid'], $row_currency)) ?> paid</div><?php } ?>
<?php } else { ?>
<span class="text-body-secondary">-</span>
<?php } ?>
</td>
<td class="text-nowrap">
<?php if ($row['paid'] > 0) { ?>
<a href="#" class="ajax-modal text-decoration-none" title="Payments" data-modal-url="modals/invoice/invoice_payments.php?invoice_id=<?= $invoice_id ?>"><?= invoiceStatusBadge($row_status) ?></a>
<?php } else { ?>
<?= invoiceStatusBadge($row_status) ?>
<?php } ?>
</td>
<td class="text-end">
<div class="dropdown dropstart">
<button class="btn btn-sm btn-light" title="More actions" type="button" data-bs-toggle="dropdown"><i class="fas fa-fw fa-ellipsis-v"></i></button>
<div class="dropdown-menu">
<a class="dropdown-item" href="<?= $row_url ?>"><i class="fas fa-fw fa-eye text-secondary me-2"></i>Open</a>
<a class="dropdown-item" href="post.php?export_invoice_pdf=<?= $invoice_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>" target="_blank"><i class="fas fa-fw fa-file-pdf text-secondary me-2"></i>Download PDF</a>
<?php if ($row_can_pay) { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/payment/payment_add.php?id=<?= $invoice_id ?>">
<i class="fas fa-fw fa-credit-card text-secondary me-2"></i>Add Payment
</a>
<?php if (isset($saved_card_clients[$row_client_id]) && in_array($row_status, ['Sent', 'Viewed'], true)) { ?>
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/payment/payment_saved_method_add.php?id=<?= $invoice_id ?>">
<i class="fas fa-fw fa-wallet text-secondary me-2"></i>Charge Saved Card
</a>
<?php } ?>
<?php } ?>
<?php if ($can_edit) { ?>
<div class="dropdown-divider"></div>
<?php if ($mail_ready && !in_array($row_status, ['Cancelled', 'Non-Billable'], true)) { ?>
<button type="submit" class="dropdown-item confirm-link" form="quickSendInvoice" name="invoice_id" value="<?= $invoice_id ?>"
data-confirm-title="Send <?= escapeHtml($row_number) ?> now?" data-confirm-text="It goes to the client's primary and billing contacts." data-confirm-button="Send">
<i class="fas fa-fw fa-bolt text-secondary me-2"></i>Quick Send
</button>
<a class="dropdown-item ajax-modal" href="#" data-modal-size="lg" data-modal-url="modals/invoice/invoice_email.php?invoice_id=<?= $invoice_id ?>">
<i class="fas fa-fw fa-paper-plane text-secondary me-2"></i>Send Email<span class="text-body-secondary">...</span>
</a>
<?php } ?>
<?php if ($row_status === 'Draft') { ?>
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/invoice/invoice_mark_sent.php?invoice_id=<?= $invoice_id ?>">
<i class="fas fa-fw fa-check text-secondary me-2"></i>Mark Sent<span class="text-body-secondary">...</span>
</a>
<?php } ?>
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/invoice/invoice_edit.php?id=<?= $invoice_id ?>">
<i class="fas fa-fw fa-edit text-secondary me-2"></i>Edit
</a>
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/invoice/invoice_copy.php?id=<?= $invoice_id ?>">
<i class="fas fa-fw fa-copy text-secondary me-2"></i>Copy
</a>
<?php } ?>
<?php if ($can_delete) { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?delete_invoice=<?= $invoice_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>"
data-confirm-text="<?= escapeHtml($row_number) ?> goes for good, with its lines, payments and history.">
<i class="fas fa-fw fa-trash me-2"></i>Delete
</a>
<?php } ?>
</div>
</div>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
</form>
<?php
$empty_state = [
'thing' => 'invoices',
'icon' => 'fa-file-invoice-dollar',
'filtered' => $q || $category_filter || $recurring_filter || $hardware_filter || $dtf !== '1970-01-01',
'views' => ['status'],
'view' => emptyStatePill($statuses, $status, '', ['Unpaid', 'Overdue']),
'text' => 'Bill a client by hand, or from an accepted quote or their unbilled time.',
'actions' => $can_edit ? [['label' => 'New Invoice', 'modal' => "modals/invoice/invoice_add.php?$client_url"]] : [],
];
require_once "../includes/filter_footer.php";
?>
</div>
<?php if ($can_edit && $mail_ready) { ?>
<?php
/*
* One hidden form for the whole page, targeted by the Quick Send buttons via
* their form="" attribute. It cannot be a form per button: the table is in
* the bulkActions form, and a nested form is invalid HTML - the browser
* drops the inner one and the click submits the bulk action instead. The
* button carries the id as its own name/value.
*/
?>
<form id="quickSendInvoice" action="post.php" method="post" class="d-none">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="email_invoice" value="1">
<input type="hidden" name="quick_send" value="1">
</form>
<?php } ?>
<script src="<?= staticFileUrl('/js/bulk_actions.js') ?>"></script>
<?php require_once "../includes/footer.php";