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

This commit is contained in:
johnnyq
2026-10-27 22:22:34 -04:00
parent 954f1e3688
commit 1e5bffff3c
49 changed files with 859 additions and 648 deletions

View File

@@ -140,14 +140,14 @@ $csrf = $_SESSION['csrf_token'];
<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 client\'s rate and terms</span>' ?>
<?= $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 client's usual SLAs
The default SLAs
<?php } ?>
</div>
</td>

View File

@@ -0,0 +1,121 @@
<?php
/*
* ITFlow - Database update to version 3.5.0 (from 3.4.9)
* Included by admin/database_updates.php - do not access directly
*
* A client has no hourly rate, net terms or SLAs of its own any more: they
* come from its contract in force, else the defaults (Admin > Settings >
* Defaults, Admin > SLAs) - functions/contract_billing.php.
*
* settings.config_default_after_hours_rate the default after-hours rate -
* NULL, none: the hourly rate
* applies after hours too
* clients.client_rate, client_net_terms dropped
* sla_assignments (client_id > 0) deleted - client 0's rows are
* the defaults and stay
*
* A client whose own rate, net terms or SLAs differ from the defaults keeps
* them, on a contract named "Standard terms" made for it here: active,
* 12-month term renewing itself, no notice, setting only what differs. It
* starts the day the client was added - before any contract the client
* already has, so that one's terms still come first where it sets them, as
* they did over the client's own. Its term is carried on to the one running
* today. An archived client's is archived with it. A client SLA pointing at
* no SLA, or at an archived one (the client's tickets got none), can't be
* kept: a contract sets an SLA or leaves the default.
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
$itflow_column = function ($table, $column) use ($mysqli) {
$result = mysqli_query($mysqli, "SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '$table' AND COLUMN_NAME = '$column' LIMIT 1");
return $result && mysqli_num_rows($result) > 0;
};
if (!$itflow_column('settings', 'config_default_after_hours_rate')) {
mysqli_query($mysqli, "ALTER TABLE `settings` ADD `config_default_after_hours_rate` decimal(15,2) DEFAULT NULL AFTER `config_default_hourly_rate`");
}
if ($itflow_column('clients', 'client_rate') && $itflow_column('clients', 'client_net_terms')) {
$itflow_settings = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_default_hourly_rate, config_default_net_terms FROM settings WHERE company_id = 1")) ?: [];
$itflow_default_rate = round(floatval($itflow_settings['config_default_hourly_rate'] ?? 0), 2);
$itflow_default_net = intval($itflow_settings['config_default_net_terms'] ?? 0);
// The default SLA for each priority - one that is archived counts as none, as it did for tickets
$itflow_parts = ['Low' => 'low', 'Medium' => 'medium', 'High' => 'high', 'Urgent' => 'urgent'];
$itflow_live_slas = [];
$itflow_sql = mysqli_query($mysqli, "SELECT sla_id FROM slas WHERE sla_archived_at IS NULL");
while ($itflow_sql && ($itflow_row = mysqli_fetch_row($itflow_sql))) {
$itflow_live_slas[intval($itflow_row[0])] = true;
}
$itflow_default_slas = array_fill_keys(array_keys($itflow_parts), 0);
$itflow_client_slas = [];
$itflow_sql = mysqli_query($mysqli, "SELECT sla_assignment_client_id, sla_assignment_priority, sla_assignment_sla_id FROM sla_assignments");
while ($itflow_sql && ($itflow_row = mysqli_fetch_assoc($itflow_sql))) {
$itflow_priority = (string) $itflow_row['sla_assignment_priority'];
$itflow_sla_id = intval($itflow_row['sla_assignment_sla_id']);
if (!isset($itflow_parts[$itflow_priority]) || !isset($itflow_live_slas[$itflow_sla_id])) {
continue;
}
if (intval($itflow_row['sla_assignment_client_id']) === 0) {
$itflow_default_slas[$itflow_priority] = $itflow_sla_id;
} else {
$itflow_client_slas[intval($itflow_row['sla_assignment_client_id'])][$itflow_priority] = $itflow_sla_id;
}
}
$itflow_today = date('Y-m-d');
$itflow_sql = mysqli_query($mysqli, "SELECT client_id, client_rate, client_net_terms, client_created_at, client_archived_at,
(SELECT MIN(contract_start_date) FROM contracts WHERE contract_client_id = client_id AND contract_start_date IS NOT NULL) AS first_contract_start
FROM clients ORDER BY client_id");
while ($itflow_sql && ($itflow_client = mysqli_fetch_assoc($itflow_sql))) {
$itflow_client_id = intval($itflow_client['client_id']);
$itflow_set = [];
// Only what differs from the defaults - a rate of nothing was no rate, the default applied
$itflow_rate = round(floatval($itflow_client['client_rate']), 2);
if ($itflow_rate > 0 && $itflow_rate != $itflow_default_rate) {
$itflow_set[] = "contract_rate_standard = " . sprintf('%.2f', $itflow_rate);
}
if ($itflow_client['client_net_terms'] !== null && intval($itflow_client['client_net_terms']) !== $itflow_default_net) {
$itflow_set[] = "contract_net_terms = " . intval($itflow_client['client_net_terms']);
}
foreach ($itflow_parts as $itflow_priority => $itflow_part) {
$itflow_sla_id = intval($itflow_client_slas[$itflow_client_id][$itflow_priority] ?? 0);
if ($itflow_sla_id && $itflow_sla_id !== $itflow_default_slas[$itflow_priority]) {
$itflow_set[] = "contract_sla_{$itflow_part}_id = $itflow_sla_id";
}
}
if (!$itflow_set) {
continue;
}
// From the day the client was added, before any contract it already has
$itflow_start = substr((string) $itflow_client['client_created_at'], 0, 10);
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $itflow_start) || $itflow_start === '0000-00-00') {
$itflow_start = $itflow_today;
}
$itflow_first = (string) $itflow_client['first_contract_start'];
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $itflow_first) && $itflow_first !== '0000-00-00' && $itflow_first <= $itflow_start) {
$itflow_start = date('Y-m-d', strtotime('-1 day', strtotime($itflow_first)));
}
// Its 12-month term carried on to the one running today
$itflow_end = date('Y-m-d', strtotime('+12 months -1 day', strtotime($itflow_start)));
while ($itflow_end < $itflow_today) {
$itflow_end = date('Y-m-d', strtotime('+12 months -1 day', strtotime('+1 day', strtotime($itflow_end))));
}
$itflow_archived = $itflow_client['client_archived_at'] ? "'" . mysqli_real_escape_string($mysqli, $itflow_client['client_archived_at']) . "'" : 'NULL';
mysqli_query($mysqli, "INSERT INTO contracts SET contract_name = 'Standard terms', contract_status = 'Active', contract_type = 'other',
contract_client_id = $itflow_client_id, contract_start_date = '$itflow_start', contract_end_date = '$itflow_end',
contract_term_months = 12, contract_auto_renew = 1, contract_notice_days = 0, contract_archived_at = $itflow_archived,
contract_notes = 'Made by the 26.11 update from the hourly rate, net terms and SLAs the client had of its own, where they differed from the defaults - a client has none of its own from 26.11.',
" . implode(', ', $itflow_set));
}
mysqli_query($mysqli, "DELETE FROM sla_assignments WHERE sla_assignment_client_id > 0");
mysqli_query($mysqli, "ALTER TABLE `clients` DROP `client_rate`, DROP `client_net_terms`");
}

View File

@@ -1,8 +1,8 @@
<?php
/*
* Get started - Billing: the hourly rate and net terms a new client starts
* with, the tax new lines get (a rate is made when there is none), how
* Get started - Billing: the hourly rate and net terms a client is billed on
* without a contract, the tax new lines get (a rate is made when there is none), how
* invoices and quotes are numbered, and - if you like - Stripe, for clients
* to pay invoices by card (getting_started_billing). The same settings as
* Admin > Defaults, Taxes, Invoices, Quotes and Payment Providers.
@@ -31,11 +31,11 @@ $gs_stripe_on = settingsChecked('stripe_on', false);
<div class="col-sm-6">
<?= settingsInput('config_default_hourly_rate', $gs_rate > 0 ? number_format($gs_rate, 2, '.', '') : '', ['icon' => 'fa-coins', 'type' => 'number', 'label' => 'Hourly rate', 'required' => true,
'min' => '0.01', 'step' => '0.01', 'inputmode' => 'decimal', 'placeholder' => '125.00', 'suffix' => escapeHtml($session_company_currency) . ' an hour',
'help' => 'For time on a ticket - a client can have their own.']) ?>
'help' => 'For time on a ticket - a client\'s contract can set its own.']) ?>
</div>
<div class="col-sm-6">
<?= settingsSelect('config_default_net_terms', (string) intval($gs_billing['config_default_net_terms'] ?? 30), settingsNetTerms(), ['icon' => 'fa-calendar-check', 'label' => 'Invoices are due',
'help' => 'A new client starts with these.']) ?>
'help' => 'Unless a client\'s contract sets its own.']) ?>
</div>
</div>

View File

@@ -16,7 +16,6 @@ require_once dirname(__DIR__) . '/post/demo_data_model.php';
$gs_demo = demoDataStatus($mysqli);
$gs_clients = mysqli_query($mysqli, "SELECT client_id, client_name FROM clients WHERE client_archived_at IS NULL ORDER BY client_created_at DESC, client_id DESC LIMIT 3");
$gs_client_count = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM clients WHERE client_archived_at IS NULL"))[0] ?? 0);
$gs_defaults = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_default_hourly_rate, config_default_net_terms FROM settings WHERE company_id = 1")) ?: [];
?>
<?php if ($gs_client_count) { ?>
@@ -40,8 +39,6 @@ $gs_defaults = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_default_h
'title', 'contact_phone_country_code', 'contact_phone', 'contact_extension', 'contact_mobile_country_code', 'contact_mobile'] as $gs_field) { ?>
<input type="hidden" name="<?= $gs_field ?>" value="">
<?php } ?>
<input type="hidden" name="rate" value="<?= escapeHtml(number_format(floatval($gs_defaults['config_default_hourly_rate'] ?? 0), 2, '.', '')) ?>">
<input type="hidden" name="net_terms" value="<?= intval($gs_defaults['config_default_net_terms'] ?? 30) ?>">
<div class="row g-3 mb-3">
<div class="col-sm-7">
<?= settingsInput('name', '', ['id' => 'gsClientName', 'icon' => 'fa-user-friends', 'label' => 'Client', 'required' => true, 'maxlength' => 200, 'placeholder' => 'Name or company']) ?>

View File

@@ -20,6 +20,7 @@ $t_hours = function ($value) {
return floatval($value) > 0 ? rtrim(rtrim(number_format(floatval($value), 2, '.', ''), '0'), '.') : '';
};
$t_term = intval($t_value('term_months', 12));
$t_hints = contractDefaultHints(); // what a field left empty falls back to
$t_slas = contractSlaChoices($t ? array_map(function ($part) use ($t) { return intval($t["contract_template_sla_{$part}_id"]); }, CONTRACT_SLA_PRIORITIES) : []);
$tab = 'templateTab' . bin2hex(random_bytes(3));
@@ -125,39 +126,39 @@ $tab = 'templateTab' . bin2hex(random_bytes(3));
<label class="form-label">Hourly rate</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-dollar-sign"></i></span>
<input type="text" inputmode="decimal" class="form-control" name="rate_standard" placeholder="The client's" value="<?= escapeHtml($t_money($t_value('rate_standard', ''))) ?>">
<input type="text" inputmode="decimal" class="form-control" name="rate_standard" placeholder="<?= escapeHtml($t_hints['rate']) ?>" value="<?= escapeHtml($t_money($t_value('rate_standard', ''))) ?>">
</div>
</div>
<div class="col-md-4">
<label class="form-label">After hours</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-moon"></i></span>
<input type="text" inputmode="decimal" class="form-control" name="rate_after_hours" placeholder="None" value="<?= escapeHtml($t_money($t_value('rate_after_hours', ''))) ?>">
<input type="text" inputmode="decimal" class="form-control" name="rate_after_hours" placeholder="<?= escapeHtml($t_hints['after_hours']) ?>" value="<?= escapeHtml($t_money($t_value('rate_after_hours', ''))) ?>">
</div>
</div>
<div class="col-md-4">
<label class="form-label">Net terms</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-calendar"></i></span>
<input type="number" class="form-control" name="net_terms" min="0" max="365" placeholder="The client's" value="<?= escapeHtml((string) $t_value('net_terms', '')) ?>">
<input type="number" class="form-control" name="net_terms" min="0" max="365" placeholder="<?= escapeHtml($t_hints['net_terms']) ?>" value="<?= escapeHtml((string) $t_value('net_terms', '')) ?>">
<span class="input-group-text">days</span>
</div>
</div>
</div>
<small class="text-secondary d-block mb-3">While the contract is in force, time bills at these rates - the after-hours one for tickets opened outside business hours - and new invoices are due on these terms. Leave one empty and the client's own applies.</small>
<small class="text-secondary d-block mb-3">While the contract is in force, time bills at these rates - the after-hours one for tickets opened outside business hours - and new invoices are due on these terms. Leave one empty and the default in Admin > Settings > Defaults applies.</small>
<label class="form-label">SLA by ticket priority</label>
<?php foreach (CONTRACT_SLA_PRIORITIES as $priority => $part) { ?>
<div class="input-group mb-2">
<span class="input-group-text" style="width: 7rem;"><i class="fa fa-fw fa-stopwatch me-2"></i><?= $priority ?></span>
<select class="form-select" name="sla_<?= $part ?>" aria-label="<?= $priority ?> priority SLA">
<option value="0">The client's usual SLA</option>
<option value="0"><?= escapeHtml($t_hints['slas'][$priority]) ?></option>
<?php foreach ($t_slas as $sla_id => $sla) { ?>
<option value="<?= $sla_id ?>"<?= intval($t_value("sla_{$part}_id", 0)) === $sla_id ? ' selected' : '' ?>><?= escapeHtml($sla['sla_name'] . ' - ' . contractSlaTargets($sla)) ?><?= $sla['sla_archived_at'] ? ' (archived)' : '' ?></option>
<?php } ?>
</select>
</div>
<?php } ?>
<small class="text-secondary">From Admin > SLAs. While the contract is in force, the client's new tickets get them.</small>
<small class="text-secondary">From Admin > SLAs. While the contract is in force, the client's new tickets get them, and the default for a priority left on the default.</small>
</div>
<div class="tab-pane fade" id="<?= $tab ?>Terms" role="tabpanel">

View File

@@ -917,7 +917,6 @@ function demoDataRemove($mysqli) {
}
mysqli_query($mysqli, "DELETE FROM calendar_events WHERE event_client_id = $client_id");
mysqli_query($mysqli, "DELETE FROM shared_items WHERE item_client_id = $client_id");
mysqli_query($mysqli, "DELETE FROM sla_assignments WHERE sla_assignment_client_id = $client_id");
mysqli_query($mysqli, "DELETE FROM logs WHERE log_client_id = $client_id");
mysqli_query($mysqli, "DELETE FROM notifications WHERE notification_client_id = $client_id");
mysqli_query($mysqli, "DELETE FROM client_tags WHERE client_id = $client_id");
@@ -1141,6 +1140,11 @@ function demoDataRemove($mysqli) {
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(*) AS total FROM $table WHERE $column = $sla_id"));
$in_use = $in_use + intval($row['total'] ?? 0);
}
// Or set by a contract or contract template of the install's own - the demo's went with its clients
foreach (['contracts' => 'contract_sla_', 'contract_templates' => 'contract_template_sla_'] as $table => $prefix) {
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(*) AS total FROM $table WHERE {$prefix}low_id = $sla_id OR {$prefix}medium_id = $sla_id OR {$prefix}high_id = $sla_id OR {$prefix}urgent_id = $sla_id"));
$in_use = $in_use + intval($row['total'] ?? 0);
}
if (!$in_use) {
mysqli_query($mysqli, "DELETE FROM slas WHERE sla_id = $sla_id");
}
@@ -1190,9 +1194,7 @@ function demoBuildClient($mysqli, $profile, $index, $context, &$counts) {
'client_type' => $profile['type'],
'client_website' => 'https://www.' . $profile['domain'],
'client_referral' => $profile['referral'],
'client_rate' => $profile['rate'],
'client_currency_code' => $currency,
'client_net_terms' => $profile['net_terms'],
'client_abbreviation' => $profile['abbreviation'],
'client_tax_id_number' => '82-' . demoSerial($profile['abbreviation'] . 'ein', 7),
// The rate where it is
@@ -1204,7 +1206,7 @@ function demoBuildClient($mysqli, $profile, $index, $context, &$counts) {
// first - a client record cannot postdate its own opening invoice
'client_created_at' => demoMonthDateTime($months, 1, 9, 14),
'client_accessed_at' => demoDateTime($index % 5, 11, 20),
], !empty($profile['tax_exempt']) ? [
], billingClientLegacyFields(), !empty($profile['tax_exempt']) ? [
// Or exempt on a certificate - a church, a school
'client_tax_exempt' => 1,
'client_tax_exempt_number' => $profile['tax_exempt'][0],
@@ -1213,16 +1215,8 @@ function demoBuildClient($mysqli, $profile, $index, $context, &$counts) {
demoAttachTags($mysqli, 'client_tags', 'client_id', 'tag_id', $client_id, 1, $profile['tags']);
// Response targets, by priority
foreach ($profile['slas'] as $priority => $sla_name) {
if (isset($context['slas'][$sla_name])) {
starterInsert($mysqli, 'sla_assignments', [
'sla_assignment_client_id' => $client_id,
'sla_assignment_priority' => $priority,
'sla_assignment_sla_id' => $context['slas'][$sla_name]['id'],
]);
}
}
// Its hourly rate, net terms and response targets - a client's come from its contract
demoBuildContract($mysqli, $profile, $client_id, $months, $context);
// Locations
$location_ids = [];
@@ -2999,6 +2993,56 @@ function demoPayment($mysqli, $invoice_id, $amount, $currency, $method, $account
]);
}
// ------------------------------
// demoBuildContract
// A client has no hourly rate, net terms or SLAs of its own - they are its
// contract's: the managed services agreement, or the terms a break fix or
// hosting only client is billed on. It starts with the client and renews
// itself a year at a time, so it is in force today. Clients with after-hours
// cover pay time and a half for tickets opened out of hours.
// ------------------------------
function demoBuildContract($mysqli, $profile, $client_id, $months, $context) {
// Before the contracts update (3.4.2) there is nothing to make it in
if (!contractsReady()) {
return 0;
}
$kind = $profile['billing'] === 'managed' ? 'managed' : ($profile['billing'] === 'hosting' ? 'other' : 'break_fix');
$start = demoMonthDate($months, 1);
$end = date('Y-m-d', strtotime('+12 months -1 day', strtotime($start)));
while ($end < date('Y-m-d')) {
$end = date('Y-m-d', strtotime('+12 months -1 day', strtotime('+1 day', strtotime($end))));
}
$fields = [
'contract_name' => ['managed' => 'Managed Services Agreement', 'other' => 'Hosting Agreement', 'break_fix' => 'Break Fix Terms'][$kind],
'contract_status' => 'Active',
'contract_type' => $kind,
'contract_client_id' => $client_id,
'contract_rate_standard' => $profile['rate'],
'contract_net_terms' => intval($profile['net_terms']),
'contract_support_hours' => 'Weekdays 8:00 to 18:00',
'contract_start_date' => $start,
'contract_end_date' => $end,
'contract_term_months' => 12,
'contract_auto_renew' => 1,
'contract_notice_days' => $kind === 'managed' ? 60 : 0,
'contract_created_at' => demoMonthDateTime($months, 1, 9, 20),
];
if (in_array('After Hours Support', $profile['tags'])) {
// Time and a half, to the nearest 5
$fields['contract_rate_after_hours'] = number_format(round(floatval($profile['rate']) * 1.5 / 5) * 5, 2, '.', '');
}
foreach ($profile['slas'] as $priority => $sla_name) {
if (isset($context['slas'][$sla_name])) {
$fields['contract_sla_' . strtolower($priority) . '_id'] = intval($context['slas'][$sla_name]['id']);
}
}
return starterInsert($mysqli, 'contracts', $fields);
}
// ------------------------------
// demoBuildBilling
// Managed clients run on an agreement with a monthly invoice behind it. Break

View File

@@ -2,8 +2,9 @@
/*
* Admin > Settings > Defaults (admin/settings_default.php):
* edit_default_settings saves the start page, calendar, a new client's net
* terms and hourly rate, and the accounts and payment methods. A start page
* edit_default_settings saves the start page, calendar, the default net terms,
* hourly rate and after-hours rate - what a client is billed on when no
* contract in force sets it - and the accounts and payment methods. A start page
* that isn't a page of ITFlow, a calendar or account that isn't there (an
* archived account only if it is the one saved), a payment method not in
* Admin > Payment Methods (but the one saved) or a rate that isn't one comes
@@ -46,6 +47,17 @@ if (isset($_POST['edit_default_settings'])) {
}
$after['config_default_hourly_rate'] = $rate;
// The after-hours rate (database 3.5.0) - empty is none, and after-hours time bills at the hourly rate
if (billingAfterHoursReady() && array_key_exists('config_default_after_hours_rate', $_POST)) {
$before += settingsCurrent(['config_default_after_hours_rate']);
$after_hours_text = trim((string) $_POST['config_default_after_hours_rate']);
$after_hours = $after_hours_text === '' ? null : billingNumberInput($after_hours_text);
if ($after_hours_text !== '' && ($after_hours === null || $after_hours < 0 || $after_hours >= 1000000)) {
$errors['config_default_after_hours_rate'] = 'An amount, such as 175.00 - or leave it empty for the hourly rate.';
}
$after['config_default_after_hours_rate'] = $after_hours !== null && round($after_hours, 2) > 0 ? round($after_hours, 2) : null;
}
foreach ($accounts as $account) {
$after[$account] = intval($in[$account]);
if ($after[$account] && $after[$account] !== intval($before[$account])

View File

@@ -36,7 +36,8 @@ $summaries = [
'localization' => escapeHtml(implode(' · ', array_filter([$localization['locales'][$company['company_locale'] ?? ''] ?? '', $company['company_currency'] ?? '', $config_timezone]))),
'theme' => '<span class="itflow-settings-dot theme-' . escapeHtml($config_theme) . '"></span>' . escapeHtml($themes[$config_theme] ?? $config_theme) . (file_exists(dirname(__DIR__) . '/uploads/favicon.ico') ? ' · its own tab icon' : ''),
'default' => 'Opens on ' . escapeHtml($start_pages[$config_start_page] ?? $config_start_page) . ' · ' . ($config_default_net_terms ? 'net ' . intval($config_default_net_terms) : 'due on receipt')
. ($config_default_hourly_rate > 0 ? ' · ' . escapeHtml(numfmt_format_currency($currency_format, $config_default_hourly_rate, $session_company_currency)) . ' an hour' : ''),
. ($config_default_hourly_rate > 0 ? ' · ' . escapeHtml(numfmt_format_currency($currency_format, $config_default_hourly_rate, $session_company_currency)) . ' an hour' : '')
. (billingDefaults()['after_hours'] > 0 ? ', ' . escapeHtml(numfmt_format_currency($currency_format, billingDefaults()['after_hours'], $session_company_currency)) . ' after hours' : ''),
'ticket' => 'Next ' . escapeHtml($config_ticket_prefix . $config_ticket_next_number) . ' · email to ticket ' . $on_off($config_ticket_email_parse),
'project' => 'Next ' . escapeHtml($config_project_prefix . $config_project_next_number),
'quote' => 'Next ' . escapeHtml($config_quote_prefix . $config_quote_next_number),

View File

@@ -63,12 +63,18 @@ echo settingsPageStart('default');
<?= settingsSelect('config_default_calendar', intval($config_default_calendar), $calendars, ['icon' => 'fa-calendar']) ?>
<?= settingsSectionEnd() ?>
<?= settingsSectionStart('clients', 'New clients', 'What a new client starts with - each can have its own, on its Billing tab.') ?>
<?= settingsSectionStart('clients', 'Rates and terms', 'What a client is billed on when no contract in force sets it - a client has no rates or terms of its own; its contract\'s apply. The SLAs are in Admin > SLAs.') ?>
<div class="row g-3">
<div class="col-md-6"><?= settingsSelect('config_default_net_terms', intval($config_default_net_terms), settingsNetTerms(), ['icon' => 'fa-calendar-check',
'help' => 'When an invoice is due.']) ?></div>
<div class="col-md-6"><?= settingsInput('config_default_hourly_rate', number_format(floatval($config_default_hourly_rate), 2, '.', ''), ['icon' => 'fa-clock', 'inputmode' => 'decimal',
'placeholder' => '0.00', 'suffix' => escapeHtml($session_company_currency) . ' an hour', 'help' => 'And what time bills at for a client with none of its own.']) ?></div>
'placeholder' => '0.00', 'suffix' => escapeHtml($session_company_currency) . ' an hour', 'help' => 'What time bills at.']) ?></div>
<?php if (billingAfterHoursReady()) {
$after_hours_saved = billingDefaults()['after_hours']; ?>
<div class="col-md-6"><?= settingsInput('config_default_after_hours_rate', $after_hours_saved > 0 ? number_format($after_hours_saved, 2, '.', '') : '', ['icon' => 'fa-moon', 'inputmode' => 'decimal',
'placeholder' => 'The hourly rate', 'suffix' => escapeHtml($session_company_currency) . ' an hour',
'help' => 'What time bills at on a ticket opened outside the business days and hours in Admin > SLAs. Empty, the hourly rate.']) ?></div>
<?php } ?>
</div>
<?= settingsSectionEnd() ?>

View File

@@ -31,7 +31,7 @@ while ($holiday_row = mysqli_fetch_assoc($sql_holidays)) {
$holiday_year = intval(date('Y'));
// Global default assignments: [priority] = sla_id (per-client overrides live on the client edit modal)
// Default assignments: [priority] = sla_id - a client's contract can set its own (functions/contract_billing.php)
$assignments = [];
$sql_assignments = mysqli_query($mysqli, "SELECT sla_assignment_priority, sla_assignment_sla_id FROM sla_assignments WHERE sla_assignment_client_id = 0");
while ($assignment_row = mysqli_fetch_assoc($sql_assignments)) {
@@ -51,7 +51,7 @@ while ($assignment_row = mysqli_fetch_assoc($sql_assignments)) {
<div class="card-body">
<p class="text-muted">SLAs are optional. Tickets only get response/resolution targets when an assignment below matches their client and priority - with nothing assigned, nothing changes.</p>
<p class="text-muted">SLAs are optional. A new ticket gets the SLA its client's contract sets for its priority, else the default for the priority below - with nothing assigned, nothing changes.</p>
<div class="table-responsive-sm">
<table class="table table-striped table-borderless table-hover">
@@ -132,7 +132,7 @@ while ($assignment_row = mysqli_fetch_assoc($sql_assignments)) {
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<strong>Default (all clients)</strong>
<strong>Default (for a client whose contract sets none)</strong>
<div class="row g-2 mt-2">
<?php foreach (['Low', 'Medium', 'High', 'Urgent'] as $priority) {
$selected_sla_id = $assignments[0][$priority] ?? 0;
@@ -151,7 +151,7 @@ while ($assignment_row = mysqli_fetch_assoc($sql_assignments)) {
<button type="submit" name="save_sla_assignments" class="btn btn-primary"><i class="fas fa-check me-2"></i>Save Defaults</button>
</form>
<small class="text-muted d-block mt-2">Per-client overrides are set on each client (edit client, Notes tab). A contract in force for a client comes first, for the priorities it sets an SLA for.</small>
<small class="text-muted d-block mt-2">A client has no SLAs of its own: a contract in force for the client comes first, for the priorities it sets an SLA for, and these defaults cover the rest.</small>
</div>
</div>