Projects modernization: projects list with Active/Mine/Overdue/Due soon/Stalled/Over budget/Ready to close/Completed/Archived/All views, ticket and task progress bars, time logged against budget with unbilled hours, due badges, manager changed in place, manager/client/created filters, bulk set manager, set due date, complete, reopen, archive, restore and delete, CSV/PDF export; project page with header actions, attention banners, tickets/tasks/time/due stat cards, Tickets/Tasks/Activity tabs, scope, details and team with hours; project quick look; New/Edit Project with client contact, start date, budget hours and template that can assign its tickets to the manager; Add tickets by picking or by number; add a project template's tickets to an existing project; template ticket subjects fill in [Client], [Project], [Month], [Quarter] and [Year]; project templates admin rework with ordered ticket templates, multi-add, duplicate, archive and restore; projects on the ticket page, client overview, dashboard, calendar, global search and client actions menu; New Ticket offers internal projects; tickets only join a project of their own client or an internal one; completing a project is checked on the server and completed projects can be reopened; deleting a project keeps its tickets; deleting a client deletes its projects; bulk ticket creation limits project choices to matching clients; project template reordering limited to admins; new manager notifications; new functions/project.php; removed project_link_closed_ticket.php; DB 2.8.6 adds project_start, project_budget_hours, project_contact_id and a ticket_project_id index, and cleans up orphaned projects, ticket project links and template links

This commit is contained in:
johnnyq
2026-10-15 16:35:00 -04:00
parent 64d938db7f
commit bbb2456f0b
44 changed files with 3617 additions and 1550 deletions

661
functions/project.php Normal file
View File

@@ -0,0 +1,661 @@
<?php
/*
* Projects - work bigger than one ticket: onboarding a client, a server
* refresh, an office move, a laptop rollout. A project holds tickets (each
* with its tasks and its time); its progress is how many of those are done
* and its time is the time logged on them, against the hours sold for it
* when there is a budget. A project with no client is internal work.
*
* Open means neither completed nor archived. A completed project can be
* reopened, an archived one restored.
*/
/** "Due soon" - due within this many days. */
const PROJECT_SOON_DAYS = 14;
/** "Stalled" - an open project whose tickets have not moved for this many days. */
const PROJECT_STALLED_DAYS = 14;
/** SQL: neither completed nor archived. On projects. */
const PROJECT_OPEN_SQL = "(projects.project_completed_at IS NULL AND projects.project_archived_at IS NULL)";
/** May this agent do this with projects? They go with tickets: 1 is to see them, 2 to work them, 3 to delete them. */
function projectCan($level) {
return lookupUserPermission('module_support') >= $level;
}
/** One project by id, with its client, manager and client contact - raw, or null. */
function projectRow($project_id) {
global $mysqli;
$project_id = intval($project_id);
if (!$project_id) {
return null;
}
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT projects.*, client_name, user_name, user_avatar,
contact_name, contact_title, contact_email, contact_phone, contact_phone_country_code, contact_extension,
contact_mobile, contact_mobile_country_code, contact_photo, contact_archived_at
FROM projects
LEFT JOIN clients ON client_id = project_client_id
LEFT JOIN users ON user_id = project_manager
LEFT JOIN contacts ON contact_id = project_contact_id AND project_contact_id > 0
WHERE project_id = $project_id LIMIT 1"));
return $row ?: null;
}
/**
* For post handlers: the project a request names, after the module and
* client checks (a project with no client is internal work any agent may
* reach) - or back with an error if it is gone. Sets the global $client_id.
*/
function projectForRequest($project_id, $level) {
global $client_id;
enforceUserPermission('module_support', $level);
$row = projectRow($project_id);
if (!$row) {
flashAlert('That project no longer exists.', 'error');
redirect();
}
$client_id = intval($row['project_client_id']);
if ($client_id) {
enforceClientAccess();
}
return $row;
}
/**
* For modals: the project a modal is opened for, after the same checks - or
* the modal's error, and nothing more. $open_only refuses one that is
* completed or archived. Sets the global $client_id.
*/
function projectForModal($project_id, $level = 2, $open_only = false) {
global $client_id;
enforceUserPermission('module_support', $level);
$row = projectRow($project_id);
if (!$row) {
echo json_encode(['error' => 'That project no longer exists.']);
exit;
}
$client_id = intval($row['project_client_id']);
if ($client_id) {
enforceClientAccess();
}
if ($open_only && !projectIsOpen($row)) {
echo json_encode(['error' => 'Project ' . projectReference($row) . ' is ' . (!empty($row['project_archived_at']) ? 'archived' : 'completed') . ' - reopen it first.']);
exit;
}
return $row;
}
/** For post handlers: the projects a bulk action names (project_ids[]) that this agent may act on - [id => row]. */
function projectsForBulk($level) {
global $client_id;
enforceUserPermission('module_support', $level);
$rows = [];
foreach (array_unique(array_map('intval', (array) ($_POST['project_ids'] ?? []))) as $project_id) {
$row = projectRow($project_id);
if (!$row) {
continue;
}
$client_id = intval($row['project_client_id']);
if ($client_id) {
enforceClientAccess();
}
$rows[$project_id] = $row;
}
return $rows;
}
/** Its reference - PRJ-12. */
function projectReference($row) {
return ($row['project_prefix'] ?? '') . intval($row['project_number'] ?? 0);
}
/** Still being worked - neither completed nor archived? */
function projectIsOpen($row) {
return empty($row['project_completed_at']) && empty($row['project_archived_at']);
}
/** Where the project page is - inside its client when it has one. */
function projectUrl($project_id, $client_id = 0) {
$client_id = intval($client_id);
return 'project.php?' . ($client_id ? "client_id=$client_id&" : '') . 'project_id=' . intval($project_id);
}
/** A project wherever a page mentions one: its name opening it, its reference beside it. */
function projectLink($row, $options = []) {
$url = escapeHtml(projectUrl($row['project_id'], $row['project_client_id'] ?? 0));
$html = '<a class="' . (projectIsOpen($row) ? 'text-body' : 'text-body-secondary') . '" href="' . $url . '"><i class="fas fa-fw fa-project-diagram text-secondary me-1"></i>' . escapeHtml($row['project_name']) . '</a>';
if ($options['reference'] ?? true) {
$html .= ' <span class="font-monospace small text-body-secondary">' . escapeHtml(projectReference($row)) . '</span>';
}
return $html;
}
/** Where it stands: archived, completed, upcoming (starts after today) or active. */
function projectState($row) {
if (!empty($row['project_archived_at'])) {
return 'archived';
}
if (!empty($row['project_completed_at'])) {
return 'completed';
}
if (!empty($row['project_start']) && $row['project_start'] > date('Y-m-d')) {
return 'upcoming';
}
return 'active';
}
function projectStatePill($row) {
switch (projectState($row)) {
case 'archived':
return '<span class="badge rounded-pill text-bg-secondary fw-normal">Archived</span>';
case 'completed':
return '<span class="badge rounded-pill text-bg-success fw-normal"><i class="fas fa-check me-1"></i>Completed</span>';
case 'upcoming':
return '<span class="badge rounded-pill text-bg-info fw-normal" title="Starts ' . escapeHtml(date('M j, Y', strtotime($row['project_start']))) . '"><i class="far fa-calendar me-1"></i>Not started</span>';
}
return '<span class="badge rounded-pill text-bg-primary fw-normal">Active</span>';
}
/** Whole days from today to a date: negative is past. */
function projectDaysFromToday($date) {
$today = new DateTime(date('Y-m-d'));
$then = new DateTime(substr((string) $date, 0, 10));
return intval($today->diff($then)->format('%r%a'));
}
/** "today", "tomorrow", "in 3 days", "yesterday", "3 days ago". */
function projectDaysText($days) {
$days = intval($days);
if ($days === 0) {
return 'today';
}
if ($days === 1) {
return 'tomorrow';
}
if ($days === -1) {
return 'yesterday';
}
return $days > 0 ? "in $days days" : abs($days) . ' days ago';
}
/**
* The due date for the list and the page: ['key' => overdue | soon | later
* | done | none, 'days', 'date', 'late' (completed after its due date)].
*/
function projectDueState($row) {
$due = $row['project_due'] ?? null;
if (!empty($row['project_completed_at'])) {
$late = $due && substr($row['project_completed_at'], 0, 10) > $due;
return ['key' => 'done', 'date' => $row['project_completed_at'], 'late' => $late,
'days_late' => $late ? -projectDaysFromToday($due) + projectDaysFromToday($row['project_completed_at']) : 0];
}
if (!$due) {
return ['key' => 'none'];
}
$days = projectDaysFromToday($due);
if ($days < 0) {
return ['key' => 'overdue', 'days' => $days, 'date' => $due];
}
return ['key' => $days <= PROJECT_SOON_DAYS ? 'soon' : 'later', 'days' => $days, 'date' => $due];
}
/** The due date as a pill - '' when there is none. */
function projectDuePill($row) {
$due = projectDueState($row);
switch ($due['key']) {
case 'overdue':
$late = abs($due['days']);
return '<span class="badge rounded-pill text-bg-danger" title="Due ' . escapeHtml(date('M j, Y', strtotime($due['date']))) . '"><i class="far fa-calendar-times me-1"></i>' . $late . ' day' . ($late === 1 ? '' : 's') . ' overdue</span>';
case 'soon':
return '<span class="badge rounded-pill text-bg-warning" title="Due ' . escapeHtml(date('M j, Y', strtotime($due['date']))) . '"><i class="far fa-calendar me-1"></i>Due ' . projectDaysText($due['days']) . '</span>';
case 'later':
return '<span class="badge rounded-pill bg-body-secondary text-body-secondary fw-normal" title="Due ' . projectDaysText($due['days']) . '"><i class="far fa-calendar me-1"></i>Due ' . escapeHtml(date('M j', strtotime($due['date']))) . '</span>';
case 'done':
$when = escapeHtml(date('M j, Y', strtotime($due['date'])));
return $due['late']
? '<span class="badge rounded-pill bg-warning-subtle text-warning-emphasis fw-normal" title="Completed ' . $when . ', ' . $due['days_late'] . ' day' . ($due['days_late'] === 1 ? '' : 's') . ' after its due date"><i class="fas fa-check me-1"></i>Done ' . $when . ' (late)</span>'
: '<span class="badge rounded-pill bg-success-subtle text-success-emphasis fw-normal" title="Completed ' . $when . '"><i class="fas fa-check me-1"></i>Done ' . $when . '</span>';
}
return '';
}
/** Hours for people: "45m", "3h", "12.5h". */
function projectHoursText($seconds) {
$seconds = max(0, intval($seconds));
if ($seconds < 3600) {
return round($seconds / 60) . 'm';
}
return rtrim(rtrim(number_format($seconds / 3600, 1, '.', ''), '0'), '.') . 'h';
}
/** A budget in hours as posted - a positive number up to 999999, or null for none. */
function projectBudgetHours($input) {
$input = str_replace(',', '.', trim((string) $input));
if ($input === '' || !is_numeric($input) || floatval($input) <= 0) {
return null;
}
return min(999999.0, round(floatval($input), 2));
}
/** A date as posted (YYYY-MM-DD) as SQL - quoted, or NULL. */
function projectDateSql($input) {
$input = trim((string) $input);
$date = DateTime::createFromFormat('!Y-m-d', $input);
return ($date && $date->format('Y-m-d') === $input) ? "'$input'" : 'NULL';
}
/*
* ---------- progress, time and activity ----------
*/
/** SQL: when its tickets last moved - the latest ticket update, or NULL with none. Correlated on projects. */
function projectLastActivitySql() {
return "(SELECT MAX(COALESCE(pt.ticket_updated_at, pt.ticket_created_at)) FROM tickets pt WHERE pt.ticket_project_id = projects.project_id AND pt.ticket_archived_at IS NULL)";
}
/** SQL: seconds logged on its tickets. Correlated on projects. */
function projectSecondsSql() {
return "(SELECT COALESCE(SUM(TIME_TO_SEC(pr.ticket_reply_time_worked)), 0) FROM ticket_replies pr JOIN tickets pt ON pt.ticket_id = pr.ticket_reply_ticket_id
WHERE pt.ticket_project_id = projects.project_id AND pr.ticket_reply_archived_at IS NULL)";
}
/**
* The list's views - [key => label, icon, title, where, alarm]. Over
* projects; 'mine' needs the agent. Over budget only shows when a budget
* is set on any project.
*/
function projectViews($with_budget = true) {
global $session_user_id;
$open = PROJECT_OPEN_SQL;
$started = "(projects.project_start IS NULL OR projects.project_start <= CURDATE())";
$has_tickets = "EXISTS (SELECT 1 FROM tickets rt WHERE rt.ticket_project_id = projects.project_id AND rt.ticket_archived_at IS NULL)";
$has_open = "EXISTS (SELECT 1 FROM tickets rt WHERE rt.ticket_project_id = projects.project_id AND rt.ticket_archived_at IS NULL AND rt.ticket_resolved_at IS NULL AND rt.ticket_closed_at IS NULL)";
$views = [
'' => ['label' => 'Active', 'icon' => 'fa-project-diagram', 'where' => $open, 'title' => 'Not completed or archived'],
'mine' => ['label' => 'Mine', 'icon' => 'fa-user-tie', 'where' => "$open AND projects.project_manager = " . intval($session_user_id), 'title' => 'Active projects you manage'],
'overdue' => ['label' => 'Overdue', 'icon' => 'fa-calendar-times', 'where' => "$open AND projects.project_due < CURDATE()",
'title' => 'Past their due date', 'alarm' => 'danger'],
'soon' => ['label' => 'Due soon', 'icon' => 'fa-calendar-day', 'where' => "$open AND projects.project_due BETWEEN CURDATE() AND CURDATE() + INTERVAL " . PROJECT_SOON_DAYS . " DAY",
'title' => 'Due in the next ' . PROJECT_SOON_DAYS . ' days', 'alarm' => 'warning'],
'stalled' => ['label' => 'Stalled', 'icon' => 'fa-hourglass-half', 'where' => "$open AND $started AND (NOT $has_tickets OR $has_open)
AND COALESCE(" . projectLastActivitySql() . ", projects.project_created_at) < NOW() - INTERVAL " . PROJECT_STALLED_DAYS . " DAY",
'title' => 'Started, and none of their tickets has moved in ' . PROJECT_STALLED_DAYS . ' days', 'alarm' => 'warning'],
'over_budget' => ['label' => 'Over budget', 'icon' => 'fa-hourglass-end', 'where' => "$open AND projects.project_budget_hours > 0 AND " . projectSecondsSql() . " > projects.project_budget_hours * 3600",
'title' => 'More time logged than the hours budgeted', 'alarm' => 'danger'],
'ready' => ['label' => 'Ready to close', 'icon' => 'fa-flag-checkered', 'where' => "$open AND $has_tickets AND NOT $has_open",
'title' => 'Every ticket resolved or closed - mark them complete', 'alarm' => 'success'],
'completed' => ['label' => 'Completed', 'icon' => 'fa-check', 'where' => 'projects.project_completed_at IS NOT NULL AND projects.project_archived_at IS NULL'],
'archived' => ['label' => 'Archived', 'icon' => 'fa-archive', 'where' => 'projects.project_archived_at IS NOT NULL'],
'all' => ['label' => 'All', 'where' => ''],
];
if (!$with_budget) {
unset($views['over_budget']);
}
return $views;
}
/**
* The numbers for each of these projects, in four queries - [project id =>
* tickets, open, done, overdue (open tickets past their due date), tasks,
* tasks_done, seconds, billable_seconds, unbilled_seconds, last_activity].
* Over all its tickets, whoever's they are - it is the project's progress.
*/
function projectStats(array $project_ids) {
global $mysqli;
$ids = implode(',', array_filter(array_map('intval', $project_ids)));
$stats = [];
foreach (array_filter(array_map('intval', $project_ids)) as $id) {
$stats[$id] = ['tickets' => 0, 'open' => 0, 'done' => 0, 'overdue' => 0, 'tasks' => 0, 'tasks_done' => 0,
'seconds' => 0, 'billable_seconds' => 0, 'unbilled_seconds' => 0, 'last_activity' => null];
}
if ($ids === '') {
return $stats;
}
$sql = mysqli_query($mysqli, "SELECT ticket_project_id, COUNT(*) AS ticket_count,
SUM(ticket_resolved_at IS NULL AND ticket_closed_at IS NULL) AS open_count,
SUM(ticket_resolved_at IS NULL AND ticket_closed_at IS NULL AND ticket_due_at IS NOT NULL AND ticket_due_at < NOW()) AS overdue_count,
MAX(COALESCE(ticket_updated_at, ticket_created_at)) AS last_activity
FROM tickets WHERE ticket_project_id IN ($ids) AND ticket_archived_at IS NULL GROUP BY ticket_project_id");
while ($row = mysqli_fetch_assoc($sql)) {
$id = intval($row['ticket_project_id']);
$stats[$id]['tickets'] = intval($row['ticket_count']);
$stats[$id]['open'] = intval($row['open_count']);
$stats[$id]['done'] = intval($row['ticket_count']) - intval($row['open_count']);
$stats[$id]['overdue'] = intval($row['overdue_count']);
$stats[$id]['last_activity'] = $row['last_activity'];
}
$sql = mysqli_query($mysqli, "SELECT ticket_project_id, COUNT(task_id) AS task_count, SUM(task_completed_at IS NOT NULL) AS done_count
FROM tasks JOIN tickets ON ticket_id = task_ticket_id
WHERE ticket_project_id IN ($ids) AND ticket_archived_at IS NULL GROUP BY ticket_project_id");
while ($row = mysqli_fetch_assoc($sql)) {
$id = intval($row['ticket_project_id']);
$stats[$id]['tasks'] = intval($row['task_count']);
$stats[$id]['tasks_done'] = intval($row['done_count']);
}
$sql = mysqli_query($mysqli, "SELECT ticket_project_id, SUM(TIME_TO_SEC(ticket_reply_time_worked)) AS seconds,
SUM(IF(ticket_billable = 1, TIME_TO_SEC(ticket_reply_time_worked), 0)) AS billable_seconds,
SUM(IF(ticket_billable = 1 AND ticket_invoice_id = 0, TIME_TO_SEC(ticket_reply_time_worked), 0)) AS unbilled_seconds
FROM ticket_replies JOIN tickets ON ticket_id = ticket_reply_ticket_id
WHERE ticket_project_id IN ($ids) AND ticket_reply_archived_at IS NULL GROUP BY ticket_project_id");
while ($row = mysqli_fetch_assoc($sql)) {
$id = intval($row['ticket_project_id']);
$stats[$id]['seconds'] = intval($row['seconds']);
$stats[$id]['billable_seconds'] = intval($row['billable_seconds']);
$stats[$id]['unbilled_seconds'] = intval($row['unbilled_seconds']);
}
return $stats;
}
/**
* What wants a look, worst first: overdue, over budget, ready to close
* (every ticket done), stalled, due soon. Each [key, tone, text].
*/
function projectFlags($row, $stats) {
if (!projectIsOpen($row)) {
return [];
}
$flags = [];
$due = projectDueState($row);
if ($due['key'] === 'overdue') {
$late = abs($due['days']);
$flags[] = ['key' => 'overdue', 'tone' => 'danger', 'text' => "$late day" . ($late === 1 ? '' : 's') . ' past its due date'];
}
$budget = floatval($row['project_budget_hours'] ?? 0);
if ($budget > 0 && $stats['seconds'] > $budget * 3600) {
$flags[] = ['key' => 'over_budget', 'tone' => 'danger', 'text' => projectHoursText($stats['seconds'] - $budget * 3600) . ' over its ' . projectHoursText($budget * 3600) . ' budget'];
}
if ($stats['tickets'] && !$stats['open']) {
$flags[] = ['key' => 'ready', 'tone' => 'success', 'text' => 'Every ticket is resolved or closed - ready to mark complete'];
}
$started = empty($row['project_start']) || $row['project_start'] <= date('Y-m-d');
$last = $stats['last_activity'] ?: $row['project_created_at'];
if ($started && $stats['open'] && strtotime($last) < time() - PROJECT_STALLED_DAYS * 86400) {
$flags[] = ['key' => 'stalled', 'tone' => 'warning', 'text' => 'No ticket has moved since ' . date('M j', strtotime($last))];
} elseif ($started && !$stats['tickets'] && strtotime($row['project_created_at']) < time() - PROJECT_STALLED_DAYS * 86400) {
$flags[] = ['key' => 'stalled', 'tone' => 'warning', 'text' => 'No tickets yet, ' . PROJECT_STALLED_DAYS . '+ days in'];
}
if ($due['key'] === 'soon') {
$flags[] = ['key' => 'soon', 'tone' => 'warning', 'text' => 'Due ' . projectDaysText($due['days'])];
}
return $flags;
}
/** How far along it is - a bar of tickets done, and tasks under it. */
function projectProgressHtml($stats, $compact = false) {
if (!$stats['tickets']) {
return '<span class="small text-body-secondary">No tickets yet</span>';
}
$percent = round($stats['done'] / $stats['tickets'] * 100);
$tone = $stats['done'] === $stats['tickets'] ? 'bg-success' : 'bg-primary';
$html = '<div class="itflow-project-progress" title="' . "{$stats['done']} of {$stats['tickets']} tickets resolved or closed" . '">'
. '<div class="progress" role="progressbar" aria-valuenow="' . $percent . '" aria-valuemin="0" aria-valuemax="100"><div class="progress-bar ' . $tone . '" style="width: ' . $percent . '%"></div></div>'
. '<span class="small text-nowrap">' . $stats['done'] . '/' . $stats['tickets'] . ($compact ? '' : ' <span class="text-body-secondary">tickets</span>') . '</span></div>';
if ($stats['tasks']) {
$html .= '<div class="small text-body-secondary text-nowrap mt-1" title="Tasks done on its tickets"><i class="fas fa-tasks me-1"></i>' . $stats['tasks_done'] . '/' . $stats['tasks'] . ' tasks</div>';
}
return $html;
}
/** Time logged, against the budget when there is one. */
function projectTimeHtml($row, $stats) {
$budget = floatval($row['project_budget_hours'] ?? 0);
$worked = projectHoursText($stats['seconds']);
if ($budget <= 0) {
return $stats['seconds'] ? '<span class="text-nowrap">' . $worked . '</span>' : '<span class="text-body-secondary">-</span>';
}
$used = $stats['seconds'] / ($budget * 3600);
$percent = min(100, round($used * 100));
$tone = $used > 1 ? 'bg-danger' : ($used >= .8 ? 'bg-warning' : 'bg-info');
return '<div class="itflow-project-progress" title="' . $worked . ' logged of ' . projectHoursText($budget * 3600) . ' budgeted (' . round($used * 100) . '%)">'
. '<div class="progress" role="progressbar" aria-valuenow="' . $percent . '" aria-valuemin="0" aria-valuemax="100"><div class="progress-bar ' . $tone . '" style="width: ' . $percent . '%"></div></div>'
. '<span class="small text-nowrap ' . ($used > 1 ? 'text-danger-emphasis fw-semibold' : '') . '">' . $worked . ' <span class="text-body-secondary">/ ' . projectHoursText($budget * 3600) . '</span></span></div>';
}
/**
* Who has worked on it: the agents who logged time or wrote on its tickets,
* and those with its open tickets - [user id => user_name, user_avatar,
* seconds, open (tickets of its they have open)]. Most time first.
*/
function projectTeam($project_id) {
global $mysqli;
$project_id = intval($project_id);
$team = [];
$sql = mysqli_query($mysqli, "SELECT user_id, user_name, user_avatar, SUM(TIME_TO_SEC(ticket_reply_time_worked)) AS seconds
FROM ticket_replies
JOIN tickets ON ticket_id = ticket_reply_ticket_id
JOIN users ON user_id = ticket_reply_by AND ticket_reply_type <> 'Client'
WHERE ticket_project_id = $project_id AND ticket_reply_archived_at IS NULL AND user_type = 1
GROUP BY user_id");
while ($row = mysqli_fetch_assoc($sql)) {
$team[intval($row['user_id'])] = ['user_name' => $row['user_name'], 'user_avatar' => $row['user_avatar'], 'seconds' => intval($row['seconds']), 'open' => 0];
}
$sql = mysqli_query($mysqli, "SELECT user_id, user_name, user_avatar, COUNT(*) AS open_count
FROM tickets JOIN users ON user_id = ticket_assigned_to
WHERE ticket_project_id = $project_id AND ticket_archived_at IS NULL AND ticket_resolved_at IS NULL AND ticket_closed_at IS NULL
GROUP BY user_id");
while ($row = mysqli_fetch_assoc($sql)) {
$id = intval($row['user_id']);
$team[$id] = ($team[$id] ?? ['user_name' => $row['user_name'], 'user_avatar' => $row['user_avatar'], 'seconds' => 0, 'open' => 0]);
$team[$id]['open'] = intval($row['open_count']);
}
uasort($team, function ($a, $b) {
return [$b['seconds'], $b['open']] <=> [$a['seconds'], $a['open']];
});
return $team;
}
/**
* The latest replies and notes across its tickets - the ones this agent may
* reach - newest first, each with its ticket and who wrote it.
*/
function projectActivity($project_id, $limit = 20) {
global $mysqli;
$project_id = intval($project_id);
$sql = mysqli_query($mysqli, "SELECT ticket_reply_id, ticket_reply, ticket_reply_type, ticket_reply_time_worked, ticket_reply_created_at,
ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_client_id, ticket_resolved_at, ticket_closed_at,
user_name, user_avatar, contact_name
FROM ticket_replies
JOIN tickets ON ticket_id = ticket_reply_ticket_id
LEFT JOIN users ON ticket_reply_type <> 'Client' AND user_id = ticket_reply_by
LEFT JOIN contacts ON ticket_reply_type = 'Client' AND contact_id = ticket_reply_by
WHERE ticket_project_id = $project_id AND ticket_reply_archived_at IS NULL AND ticket_archived_at IS NULL " . clientScopeSql('ticket_client_id') . "
ORDER BY ticket_reply_id DESC LIMIT " . intval($limit));
$rows = [];
while ($row = mysqli_fetch_assoc($sql)) {
$rows[] = $row;
}
return $rows;
}
/** The first words of a reply, as plain text. */
function projectReplySnippet($html, $length = 180) {
$text = trim(preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags(str_replace(['<br>', '<br/>', '<br />', '</p>', '</div>'], ' ', (string) $html)), ENT_QUOTES, 'UTF-8')));
return mb_strlen($text) > $length ? mb_substr($text, 0, $length - 1) . '…' : $text;
}
/*
* ---------- changing projects ----------
*/
/** The next project number, taken atomically. */
function projectNextNumber() {
global $mysqli;
mysqli_query($mysqli, "UPDATE settings SET config_project_next_number = LAST_INSERT_ID(config_project_next_number), config_project_next_number = config_project_next_number + 1 WHERE company_id = 1");
return intval(mysqli_insert_id($mysqli));
}
/** A client contact a project of this client may name, else 0. Internal projects have none. */
function projectContactId($contact_id, $client_id) {
return intval($client_id) ? assetClientOwnedId('contact', $contact_id, $client_id) : 0;
}
/**
* Raise the tickets of a project template on a project, in the template's
* order, each with the template's tasks. They go to the project's client
* contact, and to its manager when $assign_to is given (an agent id).
* The client is not emailed - a project's tickets are planned work, not a
* request. [Client] and the like in a subject are filled in
* (projectTemplateSubject()). How many tickets were raised.
*/
function projectApplyTemplate($project, $project_template_id, $assign_to = 0) {
global $mysqli, $session_user_id, $session_name, $config_ticket_prefix, $config_ticket_default_billable;
$project_template_id = intval($project_template_id);
$project_id = intval($project['project_id']);
$client_id = intval($project['project_client_id']);
$contact_id = projectContactId($project['project_contact_id'] ?? 0, $client_id);
$assign_to = ticketAgentId($assign_to);
$status = $assign_to ? TICKET_STATUS_OPEN : TICKET_STATUS_NEW;
$billable = intval($config_ticket_default_billable) === 1 ? 1 : 0;
$prefix = escapeSql($config_ticket_prefix);
$template_name = escapeSql((string) getFieldById('project_templates', $project_template_id, 'project_template_name'));
$sql = mysqli_query($mysqli, "SELECT ticket_templates.ticket_template_id, ticket_template_subject, ticket_template_name, ticket_template_details
FROM project_template_ticket_templates
JOIN ticket_templates ON ticket_templates.ticket_template_id = project_template_ticket_templates.ticket_template_id
WHERE project_template_ticket_templates.project_template_id = $project_template_id
ORDER BY ticket_template_order ASC, ticket_template_name ASC");
$raised = 0;
while ($row = mysqli_fetch_assoc($sql)) {
$subject = trim((string) $row['ticket_template_subject']) !== '' ? $row['ticket_template_subject'] : $row['ticket_template_name'];
$subject = escapeSql(mb_substr(projectTemplateSubject($subject, $project), 0, 500));
$details = mysqli_real_escape_string($mysqli, (string) $row['ticket_template_details']);
mysqli_query($mysqli, "UPDATE settings SET config_ticket_next_number = LAST_INSERT_ID(config_ticket_next_number), config_ticket_next_number = config_ticket_next_number + 1 WHERE company_id = 1");
$ticket_number = intval(mysqli_insert_id($mysqli));
$url_key = randomString(32);
mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$prefix', ticket_number = $ticket_number, ticket_source = 'Project',
ticket_subject = '$subject', ticket_details = '$details', ticket_priority = 'Medium', ticket_billable = $billable, ticket_status = $status,
ticket_created_by = " . intval($session_user_id) . ", ticket_assigned_to = $assign_to, ticket_contact_id = $contact_id, ticket_url_key = '$url_key',
ticket_client_id = $client_id, ticket_invoice_id = 0, ticket_project_id = $project_id");
$ticket_id = mysqli_insert_id($mysqli);
applyTicketSla($ticket_id);
addTasksFromTicketTemplate($ticket_id, $row['ticket_template_id']);
logTicketHistory($ticket_id, "$session_name raised the ticket from project template $template_name");
triggerCustomAction('ticket_create', $ticket_id);
$raised++;
}
return $raised;
}
/**
* A template ticket's subject with what is known filled in: [Client], [Project],
* [Month], [Quarter] and [Year]. Anything else in brackets ([User], [Server
* Name]) is the agent's to fill in, and stays.
*/
function projectTemplateSubject($subject, $project) {
$month = intval(date('n'));
$known = [
'project' => (string) $project['project_name'],
'month' => date('F Y'),
'quarter' => 'Q' . intval(ceil($month / 3)) . ' ' . date('Y'),
'year' => date('Y'),
];
if (intval($project['project_client_id']) && !empty($project['client_name'])) {
$known['client'] = (string) $project['client_name'];
}
return preg_replace_callback('/\[(client|project|month|quarter|year)\]/i', function ($match) use ($known) {
return $known[strtolower($match[1])] ?? $match[0];
}, (string) $subject);
}
/**
* Put a ticket on a project, when it may go there: the agent may reach it,
* and it is the project's client's (an internal project takes any). '' when
* done (or it was on it already), else why not.
*/
function projectAddTicket($project, $ticket) {
global $mysqli, $session_name;
$ticket_id = intval($ticket['ticket_id']);
$ticket_client_id = intval($ticket['ticket_client_id']);
$project_client_id = intval($project['project_client_id']);
if ($ticket_client_id && !hasClientAccess($ticket_client_id)) {
return 'not yours to reach';
}
if ($project_client_id && $ticket_client_id !== $project_client_id) {
return "another client's";
}
if (intval($ticket['ticket_project_id']) === intval($project['project_id'])) {
return '';
}
mysqli_query($mysqli, "UPDATE tickets SET ticket_project_id = " . intval($project['project_id']) . ", ticket_updated_at = ticket_updated_at WHERE ticket_id = $ticket_id");
logTicketHistory($ticket_id, "$session_name added the ticket to project " . escapeSql(projectReference($project) . ' - ' . $project['project_name']));
return '';
}
/** Take a ticket off its project. */
function projectRemoveTicket($ticket) {
global $mysqli, $session_name;
$ticket_id = intval($ticket['ticket_id']);
mysqli_query($mysqli, "UPDATE tickets SET ticket_project_id = 0, ticket_updated_at = ticket_updated_at WHERE ticket_id = $ticket_id");
logTicketHistory($ticket_id, "$session_name took the ticket out of its project");
}
/** Mark it complete - only once none of its tickets is open. '' when done, else why not. */
function projectComplete($row) {
global $mysqli, $session_name;
$project_id = intval($row['project_id']);
if (!projectIsOpen($row)) {
return 'it is not open';
}
$open = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM tickets WHERE ticket_project_id = $project_id AND ticket_archived_at IS NULL AND ticket_resolved_at IS NULL AND ticket_closed_at IS NULL"))[0]);
if ($open) {
return "$open of its tickets " . ($open === 1 ? 'is' : 'are') . ' still open';
}
mysqli_query($mysqli, "UPDATE projects SET project_completed_at = NOW() WHERE project_id = $project_id");
logAudit("Project", "Close", "$session_name completed project " . escapeSql(projectReference($row) . ' - ' . $row['project_name']), intval($row['project_client_id']), $project_id);
return '';
}
/** Bring a completed (not archived) project back to active. */
function projectReopen($row) {
global $mysqli, $session_name;
$project_id = intval($row['project_id']);
mysqli_query($mysqli, "UPDATE projects SET project_completed_at = NULL WHERE project_id = $project_id");
logAudit("Project", "Reopen", "$session_name reopened project " . escapeSql(projectReference($row) . ' - ' . $row['project_name']), intval($row['project_client_id']), $project_id);
}
function projectArchive($row) {
global $mysqli, $session_name;
$project_id = intval($row['project_id']);
mysqli_query($mysqli, "UPDATE projects SET project_archived_at = NOW() WHERE project_id = $project_id");
logAudit("Project", "Archive", "$session_name archived project " . escapeSql(projectReference($row) . ' - ' . $row['project_name']), intval($row['project_client_id']), $project_id);
}
function projectRestore($row) {
global $mysqli, $session_name;
$project_id = intval($row['project_id']);
mysqli_query($mysqli, "UPDATE projects SET project_archived_at = NULL WHERE project_id = $project_id");
logAudit("Project", "Restore", "$session_name restored project " . escapeSql(projectReference($row) . ' - ' . $row['project_name']), intval($row['project_client_id']), $project_id);
}
/** Delete it for good. Its tickets stay - just no longer part of a project. */
function projectDelete($row) {
global $mysqli, $session_name;
$project_id = intval($row['project_id']);
$name = escapeSql(projectReference($row) . ' - ' . $row['project_name']);
$sql = mysqli_query($mysqli, "SELECT ticket_id FROM tickets WHERE ticket_project_id = $project_id");
while ($ticket = mysqli_fetch_row($sql)) {
logTicketHistory(intval($ticket[0]), "$session_name deleted project $name, which the ticket was part of");
}
mysqli_query($mysqli, "UPDATE tickets SET ticket_project_id = 0, ticket_updated_at = ticket_updated_at WHERE ticket_project_id = $project_id");
mysqli_query($mysqli, "DELETE FROM projects WHERE project_id = $project_id");
logAudit("Project", "Delete", "$session_name deleted project $name", intval($row['project_client_id']), $project_id);
}
/** Tell the new manager - an app notification linking to the project. Not themselves, not nobody. */
function projectNotifyManager($row, $manager_id) {
global $mysqli, $session_user_id, $session_name;
$manager_id = intval($manager_id);
if (!$manager_id || $manager_id === intval($session_user_id)) {
return;
}
$client_id = intval($row['project_client_id']);
$text = escapeSql("$session_name made you the manager of project " . projectReference($row) . ' - ' . $row['project_name']);
$action = escapeSql('/agent/' . projectUrl($row['project_id'], $client_id));
mysqli_query($mysqli, "INSERT INTO notifications SET notification_type = 'Project', notification = '$text', notification_action = '$action', notification_client_id = $client_id, notification_user_id = $manager_id");
}