= $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); } /** 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 'Archived'; case 'completed': return 'Completed'; case 'upcoming': return 'Not started'; } return 'Active'; } /** 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 '' . $late . ' day' . ($late === 1 ? '' : 's') . ' overdue'; case 'soon': return 'Due ' . projectDaysText($due['days']) . ''; case 'later': return 'Due ' . escapeHtml(date('M j', strtotime($due['date']))) . ''; case 'done': $when = escapeHtml(date('M j, Y', strtotime($due['date']))); return $due['late'] ? 'Done ' . $when . ' (late)' : 'Done ' . $when . ''; } 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 'No tickets yet'; } $percent = round($stats['done'] / $stats['tickets'] * 100); $tone = $stats['done'] === $stats['tickets'] ? 'bg-success' : 'bg-primary'; $html = '