Files
itflow/agent/modals/invoice/invoice_add_ticket.php
johnnyq 1e5bffff3c Rates, net terms and SLAs come from the client's contract, else the instance defaults: database 3.5.0 adds a default after-hours rate (Settings > Defaults, now Rates and terms), gives each client whose own rate, net terms or SLAs differ from the defaults an active Standard terms contract setting just those (12-month term renewing itself, starting the day the client was added or the day before its earliest contract, archived with an archived client), deletes per-client SLA assignments and drops clients.client_rate and client_net_terms; time bills at the contract's rate, its after-hours rate or the default after-hours rate, else the default rate, new invoices are due on the contract's net terms else the default, and new tickets get the contract's SLA else the default; a client's own terms still apply until the update runs; the client add and edit windows lose the rate, terms and SLA fields for a note saying what the client is billed on and where it comes from, the Clients list loses Set Hourly Rate and Set Net Terms and shows the contract's rate, the overview's Terms shows the after-hours rate and whether terms are a contract's or the defaults, a contract's page and document show what it leaves empty from another contract in force or the default so signed documents stay unchanged, contract windows show the defaults as placeholders, contract names take a proper possessive; the client import and API create make a Standard terms contract for a rate or terms that aren't the defaults (returning contract_id), API update reports them in not_kept; the client export gives the rates, terms and SLAs as billed; demo data puts each demo client's terms on a contract; changelog updated
2026-10-27 22:22:34 -04:00

197 lines
11 KiB
PHP

<?php
/*
* Add Tickets to an invoice - the client's billable tickets that are
* resolved or closed and not on an invoice yet. Each comes with the hours
* its time bills (rounded the way Admin > Settings > Invoice says), ready to
* change, and goes on as a line at the rate - or, with Bill their time off,
* goes on without a line, for work already on the invoice. ?invoice_id=
*/
require_once '../../../includes/modal_header.php';
enforceUserPermission('module_sales', 2);
$invoice_id = intval($_GET['invoice_id'] ?? 0);
$invoice = billingRow('invoice', $invoice_id);
if (!$invoice) {
exit(json_encode(['error' => 'That invoice no longer exists.']));
}
$client_id = intval($invoice['client_id']);
enforceClientAccess();
if (!billingCanEditItems('invoice', $invoice)) {
exit(json_encode(['error' => billingNumber('invoice', $invoice) . " can't take more lines - it is paid, cancelled or non-billable."]));
}
$tickets = billingUnbilledTickets($client_id);
$rules = billingTimeRules();
// One rate for them all, or each its own - a contract's after-hours rate, or a change of contract, makes more than one
$rates = array_unique(array_map(function ($t) { return sprintf('%.2f', $t['rate']); }, $tickets));
$rate = count($rates) === 1 ? floatval(reset($rates)) : ($tickets ? null : contractClientRate($client_id)['rate']);
$currency = (string) $invoice['invoice_currency_code'];
$taxes = billingTaxes();
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fas fa-fw fa-life-ring me-2"></i>Add Tickets <span class="fw-normal text-white-50 ms-1"><?= escapeHtml(billingNumber('invoice', $invoice)) ?></span></h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" autocomplete="off" id="invoiceAddTickets">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="invoice_id" value="<?= $invoice_id ?>">
<div class="modal-body p-0">
<?php if ($tickets) { ?>
<div class="px-3 py-2 small text-body-secondary border-bottom">
<?= escapeHtml($invoice['client_name']) ?>'s billable tickets, resolved or closed, that aren't on an invoice. The hours are the time logged, rounded <?= escapeHtml(billingTimeRulesText($rules)) ?> - change them before adding.
</div>
<div class="list-group list-group-flush" style="max-height: 20rem; overflow-y: auto;">
<?php foreach ($tickets as $ticket_id => $ticket) { ?>
<div class="list-group-item d-flex align-items-center gap-3" data-rate="<?= floatval($ticket['rate']) ?>">
<input class="form-check-input flex-shrink-0 mt-0" type="checkbox" name="ticket_ids[]" value="<?= $ticket_id ?>" id="addTicket<?= $ticket_id ?>" data-add-ticket>
<label class="flex-grow-1 min-w-0 mb-0" for="addTicket<?= $ticket_id ?>">
<span class="d-block text-truncate"><span class="font-monospace small text-body-secondary me-2"><?= escapeHtml($ticket['ticket_prefix'] . $ticket['ticket_number']) ?></span><?= escapeHtml($ticket['ticket_subject']) ?></span>
<span class="small text-body-secondary">
<i class="far fa-clock me-1"></i><?= $ticket['seconds'] ? billingDuration($ticket['seconds']) . ' logged' : '<span class="text-warning-emphasis">No time logged</span>' ?>
&middot; done <?= escapeHtml(date('Y-m-d', strtotime((string) ($ticket['ticket_resolved_at'] ?: $ticket['ticket_closed_at'])))) ?>
<?= $ticket['prepaid'] > 0 ? '&middot; less ' . escapeHtml(contractHoursText($ticket['prepaid'])) . ' prepaid' : '' ?>
<?php if ($rate === null) { ?>
&middot; <span title="<?= escapeHtml(ucfirst($ticket['rate_note'])) ?>"><?= in_array($ticket['rate_source'], ['after_hours', 'default_after_hours'], true) ? '<i class="fas fa-moon me-1"></i>' : '' ?><?= escapeHtml(billingMoney($ticket['rate'], $currency)) ?>/hr</span>
<?php } ?>
</span>
</label>
<div class="input-group input-group-sm flex-shrink-0" style="width: 6.5rem;" data-add-ticket-hours>
<input type="text" class="form-control text-end" name="hours[<?= $ticket_id ?>]" value="<?= billingQuantity($ticket['hours']) ?>" inputmode="decimal" aria-label="Hours to bill">
<span class="input-group-text">h</span>
</div>
</div>
<?php } ?>
</div>
<div class="p-3 border-top">
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch" name="bill_time" value="1" id="addTicketsBill" checked data-add-ticket-bill>
<label class="form-check-label" for="addTicketsBill">Bill their time - add lines at the hourly rate</label>
</div>
<div class="row g-3" data-add-ticket-lines>
<div class="col-12">
<label class="form-label">Line</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-cube"></i></span>
<input type="text" class="form-control" name="item_name" maxlength="200" value="<?= escapeHtml($rules['item_name']) ?>" required>
</div>
</div>
<div class="col-sm-4">
<label class="form-label">Rate</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-dollar-sign"></i></span>
<input type="text" class="form-control text-end" name="price" inputmode="decimal" value="<?= $rate !== null ? number_format($rate, 2, '.', '') : '' ?>" placeholder="Each its own" data-add-ticket-rate>
</div>
<?php if ($rate === null) { ?>
<div class="form-text">Each ticket at its own rate - a contract's sets some. Type one to bill them all at it.</div>
<?php } ?>
</div>
<div class="col-sm-4">
<label class="form-label">Tax</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-percent"></i></span>
<select class="form-select" name="tax_id" data-add-ticket-tax>
<?= taxOptionsHtml($taxes, taxDefaultFor($client_id, 'time')) ?>
</select>
</div>
</div>
<div class="col-sm-4">
<label class="form-label">Lines</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-list"></i></span>
<select class="form-select" name="lines">
<option value="each">One each</option>
<option value="one">One for all</option>
</select>
</div>
</div>
</div>
<div class="form-text" data-add-ticket-nolines hidden>The tickets go on the invoice and stop being unbilled - nothing is added to its lines. For work already billed on it by hand.</div>
</div>
<?php } else { ?>
<div class="itflow-asset-empty">
<i class="fas fa-life-ring fa-2x mb-2 opacity-50"></i>
<div>No unbilled tickets - <?= escapeHtml($invoice['client_name']) ?>'s billable tickets are all on an invoice, or still open.</div>
</div>
<?php } ?>
</div>
<div class="modal-footer">
<?php if ($tickets) { ?>
<span class="me-auto small text-body-secondary" data-add-ticket-summary></span>
<button type="submit" name="add_ticket_to_invoice" class="btn btn-primary text-bold" disabled data-add-ticket-submit><i class="fas fa-check me-2"></i>Add</button>
<?php } ?>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fas fa-times me-2"></i><?= $tickets ? 'Cancel' : 'Close' ?></button>
</div>
</form>
<script>
(function () {
var form = document.getElementById('invoiceAddTickets');
if (!form || form.dataset.wired) {
return;
}
form.dataset.wired = '1';
var currency = <?= json_encode($currency) ?>;
var bill = form.querySelector('[data-add-ticket-bill]');
var lines = form.querySelector('[data-add-ticket-lines]');
var noLines = form.querySelector('[data-add-ticket-nolines]');
var rate = form.querySelector('[data-add-ticket-rate]');
var tax = form.querySelector('[data-add-ticket-tax]');
var summary = form.querySelector('[data-add-ticket-summary]');
var submit = form.querySelector('[data-add-ticket-submit]');
if (!bill) {
return;
}
function number(value) {
var n = Number(String(value || '').replace(/[\s,$]/g, ''));
return isNaN(n) ? 0 : n;
}
function money(amount) {
try {
return new Intl.NumberFormat(undefined, { style: 'currency', currency: currency || 'USD' }).format(amount);
} catch (e) {
return amount.toFixed(2);
}
}
function update() {
var count = 0;
var hours = 0;
var amount = 0;
var typed = rate.value.trim() !== '';
form.querySelectorAll('[data-add-ticket]').forEach(function (box) {
var row = box.closest('.list-group-item');
var input = row.querySelector('[data-add-ticket-hours] input');
input.disabled = !bill.checked;
if (box.checked) {
count++;
hours += number(input.value);
// The rate typed, or the ticket's own
amount += Math.round(number(input.value) * (typed ? number(rate.value) : Number(row.getAttribute('data-rate') || 0)) * 100) / 100;
}
});
lines.hidden = !bill.checked;
lines.querySelectorAll('input, select').forEach(function (input) {
input.disabled = !bill.checked;
});
noLines.hidden = bill.checked;
var option = tax.options[tax.selectedIndex];
amount += Math.round(amount * Number(option ? option.getAttribute('data-percent') || 0 : 0)) / 100;
hours = Math.round(hours * 100) / 100;
summary.textContent = count ? count + ' ticket' + (count === 1 ? '' : 's') + (bill.checked ? ' · ' + hours + ' h · ' + money(amount) : '') : 'Tick the tickets to add';
submit.disabled = count === 0;
}
form.addEventListener('change', update);
form.addEventListener('input', update);
update();
})();
</script>
<?php
require_once '../../../includes/modal_footer.php';