Files
itflow/admin/contract_templates.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

195 lines
12 KiB
PHP

<?php
/*
* Admin > Contract Templates - the agreements you offer, to start a client's
* contract from: its type, term, renewal and notice, support hours, rates,
* net terms, an SLA for each ticket priority, and the terms themselves.
* Each row says what it sets and how many contracts were started from it.
* Tabs for the ones in use and the archived ones. The work is in
* functions/contract.php; the handlers are admin/post/contract_template.php.
*/
// Default Column Sort by Filter
$sort = "contract_template_name";
$order = "ASC";
require_once "includes/inc_all_admin.php";
$views = [
'' => ['label' => 'In use', 'icon' => 'fa-file-contract', 'where' => 'contract_template_archived_at IS NULL', 'title' => 'Offered on a new contract'],
'archived' => ['label' => 'Archived', 'icon' => 'fa-archive', 'where' => 'contract_template_archived_at IS NOT NULL', 'title' => 'Kept, but not offered'],
];
$show = (string) ($_GET['show'] ?? '');
$show = isset($views[$show]) ? $show : '';
$sortable_columns = [
'contract_template_name' => "contract_template_name $order",
'contract_template_type' => "contract_template_type $order, contract_template_name ASC",
'contract_template_term_months' => "contract_template_term_months $order, contract_template_name ASC",
'contracts' => "n_contracts $order, contract_template_name ASC",
];
if (!isset($sortable_columns[$sort])) {
$sort = 'contract_template_name';
}
$rows = [];
$num_rows = [0];
$view_counts = ['' => 0, 'archived' => 0];
if (contractsReady()) {
$filters = "(contract_template_name LIKE '%$q%' OR contract_template_description LIKE '%$q%' OR contract_template_support_hours LIKE '%$q%')";
$sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS contract_templates.*,
(SELECT COUNT(*) FROM contracts WHERE contracts.contract_template_id = contract_templates.contract_template_id) AS n_contracts
FROM contract_templates
WHERE $filters AND {$views[$show]['where']}
ORDER BY {$sortable_columns[$sort]} LIMIT $record_from, $record_to");
$num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
while ($row = mysqli_fetch_assoc($sql)) {
$rows[] = $row;
}
$counts = mysqli_fetch_row(mysqli_query($mysqli, "SELECT " . implode(', ', array_map(function ($v) {
return "COALESCE(SUM({$v['where']}), 0)";
}, $views)) . " FROM contract_templates WHERE $filters"));
$view_counts = array_combine(array_keys($views), array_map('intval', $counts));
}
$slas = contractSlaChoices();
$sort_link = function ($column, $label) use ($url_query_strings_sort, $sort, $disp, $order_icon) {
return '<a class="text-reset text-decoration-none" href="?' . escapeHtml($url_query_strings_sort) . '&amp;sort=' . $column . '&amp;order=' . $disp . '">'
. $label . ($sort === $column ? " $order_icon" : '') . '</a>';
};
$pill_url = function ($view) {
return '?' . http_build_query(array_filter(['q' => $_GET['q'] ?? '', 'show' => $view]));
};
$csrf = $_SESSION['csrf_token'];
?>
<div class="card">
<div class="card-header bg-dark py-2">
<h3 class="card-title mt-2"><i class="fa fa-fw fa-file-contract me-2"></i>Contract Templates</h3>
<div class="card-tools">
<button type="button" class="btn btn-primary ajax-modal" data-modal-url="modals/contract_template/contract_template_add.php" data-modal-size="lg">
<i class="fas fa-plus"></i><span class="d-none d-lg-inline ms-2">New Template</span>
</button>
</div>
</div>
<div class="card-header py-3">
<form autocomplete="off">
<?php if ($show !== '') { ?><input type="hidden" name="show" value="<?= escapeHtml($show) ?>"><?php } ?>
<div class="row g-2 align-items-center">
<div class="col-lg-5 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 templates..." title="Name, description or support hours">
<button class="btn btn-dark" title="Search"><i class="fa fa-search"></i></button>
</div>
</div>
</div>
</form>
<nav class="nav nav-pills itflow-asset-tabs mt-2">
<?php foreach ($views as $view_key => $view) { ?>
<a class="nav-link <?= $show === (string) $view_key ? 'active' : '' ?>" href="<?= escapeHtml($pill_url($view_key)) ?>" title="<?= escapeHtml($view['title']) ?>"><i class="fas fa-fw <?= $view['icon'] ?> me-1"></i><?= escapeHtml($view['label']) ?> <span class="badge rounded-pill ms-1"><?= intval($view_counts[$view_key]) ?></span></a>
<?php } ?>
</nav>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light text-nowrap <?php if ($num_rows[0] == 0) { echo "d-none"; } ?>">
<tr>
<th class="ps-3"><?= $sort_link('contract_template_name', 'Template') ?></th>
<th class="d-none d-md-table-cell"><?= $sort_link('contract_template_term_months', 'Term') ?></th>
<th class="d-none d-lg-table-cell">Cover</th>
<th class="text-end"><?= $sort_link('contracts', 'Contracts') ?></th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $row) {
$id = intval($row['contract_template_id']);
$name = escapeHtml($row['contract_template_name']);
$type = CONTRACT_TYPES[$row['contract_template_type']] ?? CONTRACT_TYPES['other'];
$archived = !empty($row['contract_template_archived_at']);
$edit_url = "modals/contract_template/contract_template_edit.php?id=$id";
$sla_names = [];
foreach (CONTRACT_SLA_PRIORITIES as $priority => $part) {
$sla = $slas[intval($row["contract_template_sla_{$part}_id"])] ?? null;
if ($sla) {
$sla_names[$sla['sla_name']][] = $priority;
}
}
$cover = array_filter([
$row['contract_template_support_hours'],
$row['contract_template_rate_standard'] !== null ? number_format((float) $row['contract_template_rate_standard'], 2) . '/hr' : '',
$row['contract_template_net_terms'] !== null ? 'Net ' . intval($row['contract_template_net_terms']) : '',
]);
?>
<tr data-contract-template-id="<?= $id ?>">
<td class="ps-3">
<div class="d-flex align-items-center gap-3">
<span class="itflow-asset-icon itflow-asset-tone-other d-none d-sm-inline-flex" aria-hidden="true" title="<?= escapeHtml($type['label']) ?>"><i class="fas fa-fw <?= $type['icon'] ?>"></i></span>
<div class="min-w-0">
<a class="ajax-modal fw-semibold text-body" href="#" data-modal-size="lg" data-modal-url="<?= $edit_url ?>"><?= $name ?></a>
<div class="small text-body-secondary"><?= escapeHtml(contractTypeLabel($row['contract_template_type'])) ?><?= trim((string) $row['contract_template_description']) !== '' ? ' - ' . escapeHtml($row['contract_template_description']) : '' ?></div>
<?php if ($archived) { ?>
<div class="small text-body-secondary"><i class="fas fa-fw fa-archive me-1"></i>Archived <?= date('M j, Y', strtotime($row['contract_template_archived_at'])) ?></div>
<?php } ?>
</div>
</div>
</td>
<td class="d-none d-md-table-cell small text-nowrap">
<?= escapeHtml(contractTermLabel($row['contract_template_term_months'])) ?>
<div class="text-body-secondary"><?= intval($row['contract_template_auto_renew']) ? 'Renews itself' : 'Renewed by hand' ?>, <?= intval($row['contract_template_notice_days']) ?> days notice</div>
</td>
<td class="d-none d-lg-table-cell small">
<?= $cover ? escapeHtml(implode(' · ', $cover)) : '<span class="text-body-tertiary">The default rate and terms</span>' ?>
<div class="text-body-secondary">
<?php if ($sla_names) {
echo escapeHtml(implode(' · ', array_map(function ($sla_name, $priorities) {
return "$sla_name (" . implode(', ', $priorities) . ")";
}, array_keys($sla_names), $sla_names)));
} else { ?>
The default SLAs
<?php } ?>
</div>
</td>
<td class="text-end"><?= intval($row['n_contracts']) ?: '<span class="text-body-tertiary">0</span>' ?></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 ajax-modal" href="#" data-modal-size="lg" data-modal-url="<?= $edit_url ?>"><i class="fas fa-fw fa-edit text-secondary me-2"></i>Edit</a>
<a class="dropdown-item" href="post.php?copy_contract_template=<?= $id ?>&csrf_token=<?= $csrf ?>"><i class="fas fa-fw fa-copy text-secondary me-2"></i>Copy</a>
<div class="dropdown-divider"></div>
<?php if ($archived) { ?>
<a class="dropdown-item" href="post.php?restore_contract_template=<?= $id ?>&csrf_token=<?= $csrf ?>"><i class="fas fa-fw fa-box-open text-secondary me-2"></i>Restore</a>
<?php } else { ?>
<a class="dropdown-item text-danger" href="post.php?archive_contract_template=<?= $id ?>&csrf_token=<?= $csrf ?>" title="Kept, but not offered on a new contract"><i class="fas fa-fw fa-archive me-2"></i>Archive</a>
<?php } ?>
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?delete_contract_template=<?= $id ?>&csrf_token=<?= $csrf ?>"
data-confirm-title="Delete <?= $name ?>?" data-confirm-text="<?= intval($row['n_contracts']) ? 'The ' . intval($row['n_contracts']) . ' contract' . (intval($row['n_contracts']) === 1 ? '' : 's') . ' started from it keep their terms.' : 'Nothing was started from it.' ?>" data-confirm-button="Delete">
<i class="fas fa-fw fa-trash me-2"></i>Delete
</a>
</div>
</div>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
<?php
$empty_state = [
'thing' => 'contract templates',
'icon' => 'fa-file-contract',
'filtered' => $q !== '',
'archived' => $show === 'archived',
'views' => ['show'],
'text' => 'The agreements you offer - Fully managed, Block hours, Break/fix - to start a client\'s contract from.',
'actions' => [['label' => 'New Template', 'modal' => 'modals/contract_template/contract_template_add.php', 'size' => 'lg']],
];
require_once "../includes/filter_footer.php";
?>
</div>
<?php
require_once "../includes/footer.php";