Files
itflow/agent/modals/billing/billing_item.php
johnnyq b5dc8883f1 Tags stage 1: Admin > Tags rebuilt with the seven kinds in a side nav with counts, each tag shown as it looks with its description, how many records have it (and how many of those are archived) linking to the list filtered by it, and the recurring invoice lines that count by it, search by name or description, sort by name or most used, Current and Archived tabs, Edit, Merge into, Archive, Restore and Delete per tag and bulk merge, archive, restore and delete; new shared tag window with live preview, description, color swatches or any color or hex, searchable icon grid or any Font Awesome icon by name, and kind changeable only while unused; merge window that moves records onto the kept tag once each and repoints recurring seat lines; archiving now works, with archived tags kept on their records but left out of record forms, the tag picker and the list and export filters unless already chosen; tag names unique within their kind, though tags that already share a name can still be saved; delete refused while a recurring seat line counts by the tag; record forms let admins type a new tag inline, replacing the + admin window that reloaded the page and failed for non-admins, and every kind now saves through one tagSetForRecord that keeps only its own kind's tags, each once, fixing the error on a tag sent twice for clients, contacts and assets; typing an archived tag's name restores it, and the picker refuses putting an archived tag on more records; recurring line window keeps its tag once archived; pills pick dark or white text by luminance and carry the description; removes the legacy agent tag handler and tag_model files; database 3.1.3 adds tag_description and normalises stored tag colors to hex and icons to bare names; changelog updated
2026-10-17 13:59:13 -04:00

257 lines
17 KiB
PHP

<?php
/*
* Edit a line on an invoice, quote or recurring invoice - its name and
* description, quantity, price and tax, with what it comes to as you type.
* ?type=invoice|quote|recurring&doc_id=&item_id=
*
* A line from a product keeps its link to the product; on an invoice,
* changing the quantity of a stocked product moves the stock with it. On a
* recurring invoice, the quantity can come from the client instead - their
* users, devices or a license's seats, counted again for each invoice.
*/
require_once '../../../includes/modal_header.php';
enforceUserPermission('module_sales', 2);
$type = (string) ($_GET['type'] ?? '');
$doc_id = intval($_GET['doc_id'] ?? 0);
$item_id = intval($_GET['item_id'] ?? 0);
$doc = billingType($type) ? billingRow($type, $doc_id) : null;
if (!$doc) {
exit(json_encode(['error' => 'That document no longer exists.']));
}
$client_id = intval($doc['client_id']);
enforceClientAccess();
if (!billingCanEditItems($type, $doc)) {
exit(json_encode(['error' => billingNumber($type, $doc) . " can't be changed now - " . ($type === 'quote' ? 'it has been answered or invoiced.' : 'it is paid, cancelled or non-billable.')]));
}
$item = null;
foreach (billingItems($type, $doc_id) as $line) {
if (intval($line['item_id']) === $item_id) {
$item = $line;
}
}
if (!$item) {
exit(json_encode(['error' => 'That line no longer exists.']));
}
$t = billingType($type);
$currency = (string) $doc["{$t['col']}_currency_code"];
$taxes = billingTaxes($item['item_created_at']);
$line_product = intval($item['item_product_id']) ? productRow($item['item_product_id']) : null;
$product_name = $line_product ? $line_product['product_name'] : '';
$product_type = $line_product ? $line_product['product_type'] : '';
$quantity = billingQuantity($item['item_quantity']);
$price = number_format(floatval($item['item_price']), 2, '.', '');
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fas fa-fw fa-edit me-2"></i>Edit Line <span class="fw-normal text-white-50 ms-1"><?= escapeHtml(billingNumber($type, $doc)) ?></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" data-billing-line-form>
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="billing_item_save" value="1">
<input type="hidden" name="type" value="<?= escapeHtml($type) ?>">
<input type="hidden" name="doc_id" value="<?= $doc_id ?>">
<input type="hidden" name="item_id" value="<?= $item_id ?>">
<div class="modal-body">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Item <strong class="text-danger">*</strong></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="name" maxlength="200" value="<?= escapeHtml($item['item_name']) ?>" required data-line-name>
</div>
<?php if ($product_name !== '' && $product_name !== null) { ?>
<div class="form-text"><i class="fas fa-link me-1"></i>From the <?= $product_type === 'product' ? 'product' : 'service' ?> <a href="product.php?product_id=<?= intval($item['item_product_id']) ?>"><strong><?= escapeHtml($product_name) ?></strong></a><?= $type === 'invoice' && intval($line_product['product_track_stock']) ? ' - changing the quantity moves its stock (' . escapeHtml(billingQuantity($line_product['product_stock'])) . ' on the shelf)' : '' ?>.</div>
<?php } ?>
</div>
<div class="col-12">
<label class="form-label">Description</label>
<div class="input-group">
<span class="input-group-text align-items-start pt-2"><i class="fa fa-fw fa-align-left"></i></span>
<textarea class="form-control" name="description" rows="3" placeholder="<?= $type === 'recurring' ? 'What it covers - {month}, {quarter}, {year} or {period} become the period each invoice covers' : 'What it is, the period it covers, serial numbers - shown under the item' ?>" data-line-description><?= escapeHtml($item['item_description']) ?></textarea>
</div>
</div>
<?php if ($type === 'quote') {
$line_frequency = quoteFrequency($item['item_frequency'] ?? ''); ?>
<div class="col-12">
<label class="form-label">Billed</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-redo-alt"></i></span>
<select class="form-select" name="frequency">
<?php foreach (quoteFrequencies() as $frequency_value => $frequency_label) { ?>
<option value="<?= $frequency_value ?>" <?= $line_frequency === $frequency_value ? 'selected' : '' ?>><?= $frequency_label ?></option>
<?php } ?>
</select>
</div>
<div class="form-text">A line billed each month, quarter or year goes on a recurring invoice when the quote is invoiced, and the quote shows it apart from what is billed once.</div>
</div>
<?php } ?>
<?php if ($type === 'recurring') {
$seat_options = recurringSeatOptions($client_id);
$seat_source = (string) ($item['item_seat_source'] ?? '');
$seat_filter = recurringSeatFilter($seat_source, $item['item_seat_filter'] ?? '');
// The line's own tag stays offered once archived, or on nothing the client has now, so saving doesn't drop it
$seat_tag = intval($seat_filter['tag'] ?? 0);
$seat_tag_key = ['contacts' => 'contact_tags', 'assets' => 'asset_tags'][$seat_source] ?? '';
if ($seat_tag && $seat_tag_key && !isset($seat_options[$seat_tag_key][$seat_tag]) && ($seat_tag_row = tagRow($seat_tag))) {
$seat_options[$seat_tag_key][$seat_tag] = ['tag_id' => $seat_tag, 'tag_name' => $seat_tag_row['tag_name'] . ($seat_tag_row['tag_archived_at'] ? ' (archived)' : ''),
'n' => intval(recurringSeatCount($client_id, $seat_source, ['tag' => $seat_tag]))];
}
$seat_minimum = floatval($item['item_seat_minimum'] ?? 0); ?>
<!-- Where the quantity comes from - counted again for each invoice -->
<div class="col-12">
<label class="form-label">Quantity from</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-users"></i></span>
<select class="form-select" name="seat_source" data-seat-source>
<option value="">Typed in - the same every time</option>
<option value="contacts" <?= $seat_source === 'contacts' ? 'selected' : '' ?>>Users - the client's contacts (<?= intval($seat_options['contacts']) ?> now)</option>
<option value="assets" <?= $seat_source === 'assets' ? 'selected' : '' ?>>Devices - the client's devices in use (<?= intval($seat_options['assets']) ?> now)</option>
<option value="license" <?= $seat_source === 'license' ? 'selected' : '' ?> <?= $seat_options['licenses'] || $seat_source === 'license' ? '' : 'disabled' ?>>License seats - one of the client's licenses<?= $seat_options['licenses'] ? '' : ' (they have none)' ?></option>
</select>
</div>
<div class="form-text">Counted again each time an invoice is made, so a per-user or per-device line bills what the client has that month. Devices that are lost, stolen or retired aren't counted.</div>
</div>
<div class="col-12" data-seat-for="contacts" hidden>
<label class="form-label">Which contacts</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-tag"></i></span>
<select class="form-select" name="seat_tag" data-seat-input>
<option value="0">All of them</option>
<?php foreach ($seat_options['contact_tags'] as $tag_id => $tag) { ?>
<option value="<?= $tag_id ?>" <?= $seat_source === 'contacts' && intval($seat_filter['tag'] ?? 0) === $tag_id ? 'selected' : '' ?>>Tagged <?= escapeHtml($tag['tag_name']) ?> (<?= intval($tag['n']) ?>)</option>
<?php } ?>
</select>
</div>
<div class="form-text">Tag the contacts you bill for - "Licensed user", "Staff" - to leave out the rest.</div>
</div>
<div class="col-12" data-seat-for="assets" hidden>
<label class="form-label">Which devices</label>
<div class="d-flex flex-wrap gap-2 mb-2">
<?php foreach ($seat_options['asset_types'] as $asset_type => $asset_count) {
$type_id = 'seatType' . substr(md5($asset_type), 0, 8); ?>
<input type="checkbox" class="btn-check" name="seat_types[]" value="<?= escapeHtml($asset_type) ?>" id="<?= $type_id ?>" autocomplete="off" data-seat-input
<?= $seat_source === 'assets' && in_array($asset_type, $seat_filter['types'] ?? [], true) ? 'checked' : '' ?>>
<label class="btn btn-sm btn-outline-secondary" for="<?= $type_id ?>"><i class="fas fa-fw fa-<?= getAssetIcon($asset_type) ?> me-1"></i><?= escapeHtml($asset_type) ?> <span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis ms-1"><?= $asset_count ?></span></label>
<?php } ?>
<?php if (!$seat_options['asset_types']) { ?>
<span class="small text-body-secondary">The client has no devices yet.</span>
<?php } ?>
</div>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-tag"></i></span>
<select class="form-select" name="seat_tag" data-seat-input>
<option value="0">Any tag</option>
<?php foreach ($seat_options['asset_tags'] as $tag_id => $tag) { ?>
<option value="<?= $tag_id ?>" <?= $seat_source === 'assets' && intval($seat_filter['tag'] ?? 0) === $tag_id ? 'selected' : '' ?>>Tagged <?= escapeHtml($tag['tag_name']) ?> (<?= intval($tag['n']) ?>)</option>
<?php } ?>
</select>
</div>
<div class="form-text">No types ticked counts every device - tick Desktop and Laptop for a per-workstation line, Server and Virtual Machine for per-server.</div>
</div>
<div class="col-12" data-seat-for="license" hidden>
<div class="row g-3">
<div class="col-sm-8">
<label class="form-label">License</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-cube"></i></span>
<select class="form-select" name="seat_software_id" data-seat-input>
<?php if ($seat_source === 'license' && !isset($seat_options['licenses'][intval($seat_filter['software_id'] ?? 0)])) { ?>
<option value="0" selected disabled>The license it counted is gone - pick another</option>
<?php } ?>
<?php foreach ($seat_options['licenses'] as $software_id => $license) { ?>
<option value="<?= $software_id ?>" <?= intval($seat_filter['software_id'] ?? 0) === $software_id ? 'selected' : '' ?>><?= escapeHtml($license['software_name']) ?> (<?= intval($license['used']) ?> in use<?= intval($license['software_seats']) ? ' of ' . intval($license['software_seats']) : '' ?>)</option>
<?php } ?>
</select>
</div>
</div>
<div class="col-sm-4">
<label class="form-label">Count</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-calculator"></i></span>
<select class="form-select" name="seat_count" data-seat-input>
<option value="used">Seats in use</option>
<option value="total" <?= ($seat_filter['count'] ?? '') === 'total' ? 'selected' : '' ?>>Seats bought</option>
</select>
</div>
</div>
</div>
</div>
<div class="col-sm-4" data-seat-for="any" hidden>
<label class="form-label">At least</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-arrow-down"></i></span>
<input type="text" class="form-control text-end" name="seat_minimum" inputmode="decimal" value="<?= $seat_minimum ? billingQuantity($seat_minimum) : '' ?>" placeholder="0" data-seat-input>
</div>
<div class="form-text">A contract's minimum.</div>
</div>
<div class="col-sm-8 d-flex align-items-center" data-seat-for="any" hidden>
<div class="small" data-seat-preview></div>
</div>
<?php } ?>
<div class="col-sm-4">
<label class="form-label">Quantity <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-hashtag"></i></span>
<input type="text" class="form-control text-end" name="qty" inputmode="decimal" value="<?= $quantity ?>" required data-line-qty>
</div>
</div>
<div class="col-sm-4">
<label class="form-label">Price <strong class="text-danger">*</strong></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="<?= $price ?>" required data-line-price>
</div>
<div class="form-text">Each, in <?= escapeHtml($currency) ?>. A credit can be negative.</div>
</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-line-tax>
<?= taxOptionsHtml($taxes, $item['item_tax_id']) ?>
</select>
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mt-3 pt-3 border-top">
<span class="text-body-secondary">The line comes to</span>
<span class="fs-5 fw-semibold itflow-money" data-line-preview><?= escapeHtml(billingMoney($item['item_total'], $currency)) ?></span>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary text-bold"><i class="fas fa-check me-2"></i>Save</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fas fa-times me-2"></i>Cancel</button>
</div>
</form>
<script>
(function () {
var form = document.querySelector('form[data-billing-line-form]:not([data-wired])');
if (!form) {
return;
}
form.setAttribute('data-wired', '1');
if (typeof itflowBillingLineForm === 'function') {
itflowBillingLineForm(form, { currency: <?= json_encode($currency) ?>, locale: <?= json_encode(str_replace('_', '-', (string) $session_company_locale)) ?> });
}
if (typeof itflowBillingSeatForm === 'function') {
itflowBillingSeatForm(form, <?= intval($doc_id) ?>);
}
})();
</script>
<?php
require_once '../../../includes/modal_footer.php';