Files
itflow/agent/post/project.php

588 lines
21 KiB
PHP

<?php
/*
* ITFlow - GET/POST request handler for projects
*
* Every handler loads the project through projectForRequest() or
* projectsForBulk() (functions/project.php): it exists, the agent has the
* support module at the level asked, and may reach its client - a project
* with no client is internal work any agent can reach. A ticket goes on a
* project only when the agent may reach it and it is the project's client's
* (an internal project takes any). A project is completed once none of its
* tickets is open.
*/
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
/** What the add and edit forms post, checked - or back with an error. $client_id is the project's. */
function projectFromPost($client_id) {
$name = trim((string) ($_POST['name'] ?? ''));
if ($name === '') {
flashAlert('A project needs a name.', 'error');
redirect();
}
$start = projectDateSql($_POST['start'] ?? '');
$due = projectDateSql($_POST['due_date'] ?? '');
if ($start !== 'NULL' && $due !== 'NULL' && $due < $start) {
flashAlert('The due date is before the start date.', 'error');
redirect();
}
$budget = projectBudgetHours($_POST['budget_hours'] ?? '');
return [
'name' => escapeSql(mb_substr($name, 0, 255)),
'description' => escapeSql(trim((string) ($_POST['description'] ?? ''))),
'start' => $start,
'due' => $due,
'budget' => $budget === null ? 'NULL' : number_format($budget, 2, '.', ''),
'manager' => ticketAgentId($_POST['project_manager'] ?? 0),
'contact' => projectContactId($_POST['contact_id'] ?? 0, $client_id),
];
}
/** The project page, or the list when the project is gone - inside its client when it has one. */
function projectBackUrl($client_id, $project_id = 0) {
return $project_id ? projectUrl($project_id, $client_id) : 'projects.php' . (intval($client_id) ? '?client_id=' . intval($client_id) : '');
}
if (isset($_POST['add_project'])) {
validateCSRFToken();
enforceUserPermission('module_support', 2);
// A client of the agent's, or none - internal work
$client_id = intval($_POST['client_id'] ?? 0);
if ($client_id) {
if (!getFieldById('clients', $client_id, 'client_name')) {
flashAlert('That client no longer exists.', 'error');
redirect();
}
enforceClientAccess();
}
$p = projectFromPost($client_id);
$project_template_id = intval($_POST['project_template_id'] ?? 0);
if ($project_template_id && !mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM project_templates WHERE project_template_id = $project_template_id AND project_template_archived_at IS NULL"))) {
$project_template_id = 0;
}
$project_number = projectNextNumber();
$project_prefix = escapeSql($config_project_prefix);
mysqli_query($mysqli, "INSERT INTO projects SET project_prefix = '$project_prefix', project_number = $project_number, project_name = '{$p['name']}',
project_description = '{$p['description']}', project_start = {$p['start']}, project_due = {$p['due']}, project_budget_hours = {$p['budget']},
project_manager = {$p['manager']}, project_contact_id = {$p['contact']}, project_client_id = $client_id");
$project_id = mysqli_insert_id($mysqli);
$row = projectRow($project_id);
// A template's tickets - to the manager when asked
$raised = $project_template_id ? projectApplyTemplate($row, $project_template_id, intval($_POST['assign_to_manager'] ?? 0) ? $p['manager'] : 0) : 0;
projectNotifyManager($row, $p['manager']);
logAudit("Project", "Create", "$session_name created project " . escapeSql(projectReference($row)) . " - {$p['name']}", $client_id, $project_id);
flashAlert("Project <strong>" . escapeHtml(projectReference($row) . ' - ' . $row['project_name']) . "</strong> created" . ($raised ? " with <strong>$raised</strong> ticket" . ($raised === 1 ? '' : 's') . " from the template" : ''));
redirect(projectBackUrl($client_id, $project_id));
}
if (isset($_POST['edit_project'])) {
validateCSRFToken();
$row = projectForRequest($_POST['project_id'] ?? 0, 2);
$project_id = intval($row['project_id']);
if (!empty($row['project_archived_at'])) {
flashAlert("Project <strong>" . escapeHtml(projectReference($row)) . "</strong> is archived - restore it to change it.", 'error');
redirect();
}
$p = projectFromPost($client_id);
mysqli_query($mysqli, "UPDATE projects SET project_name = '{$p['name']}', project_description = '{$p['description']}', project_start = {$p['start']},
project_due = {$p['due']}, project_budget_hours = {$p['budget']}, project_manager = {$p['manager']}, project_contact_id = {$p['contact']}
WHERE project_id = $project_id");
if ($p['manager'] !== intval($row['project_manager'])) {
projectNotifyManager($row, $p['manager']);
}
logAudit("Project", "Edit", "$session_name edited project " . escapeSql(projectReference($row)) . " - {$p['name']}", $client_id, $project_id);
flashAlert("Project <strong>" . escapeHtml(projectReference($row) . ' - ' . stripslashes($p['name'])) . "</strong> saved");
redirect();
}
// close_project is the old name, from links made before Complete
if (isset($_GET['complete_project']) || isset($_GET['close_project'])) {
validateCSRFToken();
$row = projectForRequest($_GET['complete_project'] ?? $_GET['close_project'], 2);
$why_not = projectComplete($row);
if ($why_not !== '') {
flashAlert("Project <strong>" . escapeHtml(projectReference($row)) . "</strong> can't be completed - " . escapeHtml($why_not) . ".", 'error');
redirect();
}
flashAlert("Project <strong>" . escapeHtml(projectReference($row) . ' - ' . $row['project_name']) . "</strong> completed");
redirect();
}
if (isset($_GET['reopen_project'])) {
validateCSRFToken();
$row = projectForRequest($_GET['reopen_project'], 2);
if (empty($row['project_completed_at']) || !empty($row['project_archived_at'])) {
flashAlert("Project <strong>" . escapeHtml(projectReference($row)) . "</strong> isn't completed" . (!empty($row['project_archived_at']) ? ' - it is archived, restore it first' : '') . ".", 'error');
redirect();
}
projectReopen($row);
flashAlert("Project <strong>" . escapeHtml(projectReference($row) . ' - ' . $row['project_name']) . "</strong> reopened");
redirect();
}
if (isset($_GET['archive_project'])) {
validateCSRFToken();
$row = projectForRequest($_GET['archive_project'], 2);
projectArchive($row);
flashAlert("Project <strong>" . escapeHtml(projectReference($row) . ' - ' . $row['project_name']) . "</strong> archived", 'error');
redirect();
}
if (isset($_GET['restore_project'])) {
validateCSRFToken();
$row = projectForRequest($_GET['restore_project'], 2);
projectRestore($row);
flashAlert("Project <strong>" . escapeHtml(projectReference($row) . ' - ' . $row['project_name']) . "</strong> restored");
redirect();
}
if (isset($_GET['delete_project'])) {
validateCSRFToken();
$row = projectForRequest($_GET['delete_project'], 3);
projectDelete($row);
flashAlert("Project <strong>" . escapeHtml(projectReference($row) . ' - ' . $row['project_name']) . "</strong> deleted - its tickets stay", 'error');
// From its own page, back to the list - the page is gone
$referer = (string) ($_SERVER['HTTP_REFERER'] ?? '');
if (preg_match('#/project\.php#', $referer)) {
redirect(projectBackUrl(strpos($referer, 'client_id=') !== false ? $row['project_client_id'] : 0));
}
redirect();
}
/*
* Tickets onto a project - picked from its client's tickets, or typed by
* number for older ones. Moving a ticket off another project is fine; one
* the agent can't reach, or another client's, is skipped.
*/
if (isset($_POST['link_ticket_to_project'])) {
validateCSRFToken();
$row = projectForRequest($_POST['project_id'] ?? 0, 2);
if (!projectIsOpen($row)) {
flashAlert("Project <strong>" . escapeHtml(projectReference($row)) . "</strong> isn't open - reopen it to add tickets.", 'error');
redirect();
}
$ticket_ids = array_map('intval', (array) ($_POST['tickets'] ?? []));
// Numbers typed in, with or without the prefix
$not_found = [];
foreach (preg_split('/[\s,;]+/', (string) ($_POST['ticket_numbers'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) as $typed) {
$number = intval(preg_replace('/^\D+/', '', $typed));
$found = $number ? mysqli_fetch_row(mysqli_query($mysqli, "SELECT ticket_id FROM tickets WHERE ticket_number = $number ORDER BY ticket_id DESC LIMIT 1")) : null;
if ($found) {
$ticket_ids[] = intval($found[0]);
} else {
$not_found[] = escapeHtml($typed);
}
}
$added = 0;
$skipped = [];
foreach (array_unique(array_filter($ticket_ids)) as $ticket_id) {
$ticket = ticketRow($ticket_id);
if (!$ticket) {
continue;
}
if (intval($ticket['ticket_project_id']) === intval($row['project_id'])) {
continue;
}
$why_not = projectAddTicket($row, $ticket);
if ($why_not === '') {
$added++;
} else {
$skipped[] = escapeHtml(ticketReference($ticket)) . " ($why_not)";
}
}
if ($added) {
logAudit("Project", "Edit", "$session_name added $added ticket" . ($added === 1 ? '' : 's') . " to project " . escapeSql(projectReference($row) . ' - ' . $row['project_name']), $client_id, intval($row['project_id']));
}
// One message - a second flashAlert() would replace the first
$message = array_filter([
$added ? "<strong>$added</strong> ticket" . ($added === 1 ? '' : 's') . " added to <strong>" . escapeHtml($row['project_name']) . "</strong>" : '',
$skipped ? 'Skipped ' . implode(', ', $skipped) : '',
$not_found ? 'No ticket numbered ' . implode(', ', $not_found) : '',
]);
flashAlert($message ? implode('. ', $message) : 'Nothing to add - those tickets are on the project already.', $added ? 'success' : 'error');
redirect();
}
/** Tickets off a project - from its page, ticked. Only ones on this project, and the agent may reach. */
if (isset($_POST['remove_tickets_from_project'])) {
validateCSRFToken();
$row = projectForRequest($_POST['project_id'] ?? 0, 2);
$project_id = intval($row['project_id']);
$removed = 0;
foreach (ticketsForBulk(2) as $ticket) {
if (intval($ticket['ticket_project_id']) !== $project_id) {
continue;
}
projectRemoveTicket($ticket);
$removed++;
}
if ($removed) {
logAudit("Project", "Edit", "$session_name took $removed ticket" . ($removed === 1 ? '' : 's') . " out of project " . escapeSql(projectReference($row) . ' - ' . $row['project_name']), intval($row['project_client_id']), $project_id);
}
flashAlert("<strong>$removed</strong> ticket" . ($removed === 1 ? '' : 's') . " taken out of <strong>" . escapeHtml($row['project_name']) . "</strong>", 'error');
redirect();
}
/** A project template's tickets onto a project that already exists. */
if (isset($_POST['apply_project_template'])) {
validateCSRFToken();
$row = projectForRequest($_POST['project_id'] ?? 0, 2);
if (!projectIsOpen($row)) {
flashAlert("Project <strong>" . escapeHtml(projectReference($row)) . "</strong> isn't open.", 'error');
redirect();
}
$project_template_id = intval($_POST['project_template_id'] ?? 0);
if (!mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM project_templates WHERE project_template_id = $project_template_id AND project_template_archived_at IS NULL"))) {
flashAlert('Pick a project template.', 'error');
redirect();
}
$raised = projectApplyTemplate($row, $project_template_id, intval($_POST['assign_to_manager'] ?? 0) ? $row['project_manager'] : 0);
logAudit("Project", "Edit", "$session_name added $raised ticket" . ($raised === 1 ? '' : 's') . " from a template to project " . escapeSql(projectReference($row) . ' - ' . $row['project_name']), intval($row['project_client_id']), intval($row['project_id']));
flashAlert("<strong>$raised</strong> ticket" . ($raised === 1 ? '' : 's') . " added from the template");
redirect();
}
/*
* ---------- bulk, from the projects list ----------
*/
if (isset($_POST['bulk_complete_projects'])) {
validateCSRFToken();
$done = 0;
$refused = [];
foreach (projectsForBulk(2) as $row) {
if (!projectIsOpen($row)) {
continue;
}
$why_not = projectComplete($row);
if ($why_not === '') {
$done++;
} else {
$refused[] = escapeHtml(projectReference($row));
}
}
flashAlert("<strong>$done</strong> project" . ($done === 1 ? '' : 's') . " completed"
. ($refused ? ". Still open tickets on " . implode(', ', $refused) . " - left as they were" : ''), $refused && !$done ? 'error' : 'success');
redirect();
}
if (isset($_POST['bulk_reopen_projects'])) {
validateCSRFToken();
$done = 0;
foreach (projectsForBulk(2) as $row) {
if (empty($row['project_completed_at']) || !empty($row['project_archived_at'])) {
continue;
}
projectReopen($row);
$done++;
}
flashAlert("<strong>$done</strong> project" . ($done === 1 ? '' : 's') . " reopened");
redirect();
}
if (isset($_POST['bulk_archive_projects'])) {
validateCSRFToken();
$done = 0;
foreach (projectsForBulk(2) as $row) {
if (!empty($row['project_archived_at'])) {
continue;
}
projectArchive($row);
$done++;
}
flashAlert("<strong>$done</strong> project" . ($done === 1 ? '' : 's') . " archived", 'error');
redirect();
}
if (isset($_POST['bulk_restore_projects'])) {
validateCSRFToken();
$done = 0;
foreach (projectsForBulk(2) as $row) {
if (empty($row['project_archived_at'])) {
continue;
}
projectRestore($row);
$done++;
}
flashAlert("<strong>$done</strong> project" . ($done === 1 ? '' : 's') . " restored");
redirect();
}
if (isset($_POST['bulk_delete_projects'])) {
validateCSRFToken();
$done = 0;
foreach (projectsForBulk(3) as $row) {
projectDelete($row);
$done++;
}
flashAlert("<strong>$done</strong> project" . ($done === 1 ? '' : 's') . " deleted - their tickets stay", 'error');
redirect();
}
if (isset($_POST['bulk_edit_project_manager'])) {
validateCSRFToken();
$manager_id = ticketAgentId($_POST['project_manager'] ?? 0);
$manager_name = $manager_id ? escapeSql((string) getFieldById('users', $manager_id, 'user_name')) : 'No One';
$done = 0;
foreach (projectsForBulk(2) as $row) {
if (!projectIsOpen($row) || intval($row['project_manager']) === $manager_id) {
continue;
}
mysqli_query($mysqli, "UPDATE projects SET project_manager = $manager_id WHERE project_id = " . intval($row['project_id']));
projectNotifyManager($row, $manager_id);
logAudit("Project", "Edit", "$session_name set the manager of project " . escapeSql(projectReference($row) . ' - ' . $row['project_name']) . " to $manager_name", intval($row['project_client_id']), intval($row['project_id']));
$done++;
}
flashAlert("Manager of <strong>$done</strong> project" . ($done === 1 ? '' : 's') . " set to <strong>" . escapeHtml(stripslashes($manager_name)) . "</strong>");
redirect();
}
if (isset($_POST['bulk_edit_project_due'])) {
validateCSRFToken();
$due = projectDateSql($_POST['due_date'] ?? '');
$done = 0;
$skipped = 0;
foreach (projectsForBulk(2) as $row) {
if (!projectIsOpen($row)) {
continue;
}
// Not before it starts
if ($due !== 'NULL' && !empty($row['project_start']) && trim($due, "'") < $row['project_start']) {
$skipped++;
continue;
}
mysqli_query($mysqli, "UPDATE projects SET project_due = $due WHERE project_id = " . intval($row['project_id']));
logAudit("Project", "Edit", "$session_name set the due date of project " . escapeSql(projectReference($row) . ' - ' . $row['project_name']) . " to " . ($due === 'NULL' ? 'none' : trim($due, "'")), intval($row['project_client_id']), intval($row['project_id']));
$done++;
}
flashAlert("Due date of <strong>$done</strong> project" . ($done === 1 ? '' : 's') . ($due === 'NULL' ? ' cleared' : ' set to <strong>' . escapeHtml(date('M j, Y', strtotime(trim($due, "'")))) . '</strong>')
. ($skipped ? ", <strong>$skipped</strong> skipped - they start after it" : ''));
redirect();
}
/*
* Export - the projects list's filters and view, as CSV or PDF, with each
* project's progress and hours.
*/
if (isExportRequest('export_projects')) {
validateCSRFToken();
enforceUserPermission('module_support');
$format = resolveExportFormat($_POST['export_projects']);
$filter_summary = [];
$client_id = intval($_POST['client_id'] ?? 0);
$where = [clientScopeSql('project_client_id')];
if ($client_id) {
enforceClientAccess();
$where[] = "AND project_client_id = $client_id";
$file_name_prepend = getFieldById('clients', $client_id, 'client_name') . '-';
$filter_summary['Client'] = getFieldById('clients', $client_id, 'client_name');
} else {
$file_name_prepend = "$session_company_name-";
$client_filter = (string) ($_POST['client'] ?? '');
if ($client_filter === 'internal') {
$where[] = 'AND project_client_id = 0';
$filter_summary['Client'] = 'Internal';
} elseif (intval($client_filter)) {
$where[] = 'AND project_client_id = ' . intval($client_filter);
$filter_summary['Client'] = exportClientName($client_filter);
}
}
$views = projectViews();
$show = (string) ($_POST['show'] ?? '');
if (!isset($views[$show])) {
$show = '';
}
if ($views[$show]['where'] !== '') {
$where[] = 'AND ' . $views[$show]['where'];
}
$filter_summary['Showing'] = $views[$show]['label'];
$manager = (string) ($_POST['manager'] ?? '');
if ($manager === 'none') {
$where[] = 'AND project_manager = 0';
$filter_summary['Manager'] = 'None';
} elseif (intval($manager)) {
$where[] = 'AND project_manager = ' . intval($manager);
$filter_summary['Manager'] = getFieldById('users', intval($manager), 'user_name');
}
$q = escapeSql($_POST['q'] ?? '');
if ($q !== '') {
$filter_summary['Search'] = $_POST['q'];
}
$sql = mysqli_query($mysqli, "SELECT projects.*, client_name, user_name, contact_name
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 (CONCAT(project_prefix, project_number) LIKE '%$q%' OR project_name LIKE '%$q%' OR project_description LIKE '%$q%'
OR client_name LIKE '%$q%' OR user_name LIKE '%$q%' OR contact_name LIKE '%$q%')
" . implode(' ', $where) . "
ORDER BY project_client_id = 0, client_name ASC, project_due IS NULL, project_due ASC, project_name ASC");
$rows = [];
while ($row = mysqli_fetch_assoc($sql)) {
$rows[intval($row['project_id'])] = $row;
}
$num_rows = count($rows);
if ($num_rows > 0) {
guardExportPdfRowCount($format, $num_rows);
$stats = projectStats(array_keys($rows));
$export = beginExport('projects', $format, $file_name_prepend . 'Projects', 'Projects', summarizeExportFilters($filter_summary));
foreach ($rows as $project_id => $row) {
$s = $stats[$project_id];
$row['project_reference'] = projectReference($row);
$row['client_name'] = intval($row['project_client_id']) ? $row['client_name'] : 'Internal';
$row['project_state'] = ucfirst(projectState($row));
$row['project_completed'] = $row['project_completed_at'] ? substr($row['project_completed_at'], 0, 10) : '';
$row['tickets_done'] = $s['done'];
$row['tickets_total'] = $s['tickets'];
$row['tasks_done'] = $s['tasks_done'];
$row['tasks_total'] = $s['tasks'];
$row['hours_logged'] = round($s['seconds'] / 3600, 2);
$row['hours_unbilled'] = round($s['unbilled_seconds'] / 3600, 2);
addExportRow($export, $row);
}
finishExport($export);
logAudit("Project", "Export", "$session_name exported $num_rows project(s) to a " . strtoupper($format) . " file", $client_id);
exit;
}
flashAlert('No projects match - nothing to export.', 'error');
redirect();
}