diff --git a/assets/default/css/theme.css b/assets/default/css/theme.css index 8adafc32edbd4b7335de5e75769d25a46a86bfda..26680176598c1b8916aaccacb041713d34074a16 100644 --- a/assets/default/css/theme.css +++ b/assets/default/css/theme.css @@ -726,7 +726,7 @@ label.required, span.required { border-radius: 4px; } #reply { - margin-top: 20px; + margin-top: 5px; padding: 10px; background: #f9f9f9; border: 1px solid #ccc; @@ -855,44 +855,6 @@ a.refresh { text-align: left; padding: 3px 8px; } -#ticketThread table.response, -#ticketThread table.message { - margin-top: 10px; - border: 1px solid #aaa; - border-bottom: 2px solid #aaa; -} -#ticketThread table th { - text-align: left; - border-bottom: 1px solid #aaa; - font-size: 12px; - padding: 5px; -} -#ticketThread table th span { - font-weight: normal; - color: #888; - padding-left: 20px; -} -#ticketThread .message th { - background: #d8efff; -} -#ticketThread .response th { - background: #ddd; -} -#ticketThread .info { - padding: 2px; - background: #f9f9f9; - border-top: 1px solid #ddd; - height: 16px; - line-height: 16px; -} -#ticketThread .info a { - display: inline-block; - margin: 5px 10px 5px 0; - height: 16px; - line-height: 16px; - background-position: 0 50%; - background-repeat: no-repeat; -} .action-button { -webkit-border-radius: 3px; -moz-border-radius: 3px; @@ -1048,6 +1010,7 @@ img.sign-in-image { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + vertical-align: bottom; } .image-hover a.action-button:hover, .image-hover a.action-button { @@ -1079,3 +1042,220 @@ table.custom-data .headline { #ticketInfo h1 small { font-weight: normal; } +.thread-entry { + margin-bottom: 15px; +} +.thread-entry.avatar { + margin-left: 60px; +} +.thread-entry.response.avatar { + margin-right: 60px; + margin-left: 0; +} +.thread-entry > .avatar { + margin-left: -60px; + display:inline-block; + width:48px; + height:auto; + border-radius: 5px; +} +.thread-entry.response > .avatar { + margin-left: initial; + margin-right: -60px; +} +img.avatar { + border-radius: inherit; +} +.thread-entry .header { + padding: 8px 0.9em; + border: 1px solid #ccc; + border-color: rgba(0,0,0,0.2); + border-radius: 5px 5px 0 0; +} +.thread-entry.avatar .header:before { + position: absolute; + top: 7px; + right: -8px; + content: ''; + border-top: 8px solid transparent; + border-bottom: 8px solid transparent; + border-left: 8px solid #b0b0b0; + display: inline-block; +} +.thread-entry.avatar .header:after { + position: absolute; + top: 7px; + right: -8px; + content: ''; + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + display: inline-block; + margin-top: 1px; +} + +.thread-entry.avatar .header { + position: relative; +} + +.thread-entry.response .header { + background:#dddddd; +} +.thread-entry.avatar.response .header:after { + border-left: 7px solid #dddddd; + margin-right: 1px; +} + +.thread-entry.message .header { + background:#C3D9FF; +} +.thread-entry.avatar.message .header:before { + top: 7px; + left: -8px; + right: initial; + border-left: none; + border-right: 8px solid #CCC; +} +.thread-entry.avatar.message .header:before { + border-right-color: #9cadcc; +} +.thread-entry.avatar.message .header:after { + top: 7px; + left: -8px; + right: initial; + border-left: none; + border-right: 7px solid #c3d9ff; + margin-left: 1px; +} + +.thread-entry .header .title { + max-width: 500px; + vertical-align: bottom; + display: inline-block; + margin-left: 15px; +} + +.thread-entry .thread-body { + border: 1px solid #ddd; + border-top: none; + border-bottom:2px solid #aaa; + border-radius: 0 0 5px 5px; +} +.thread-body .attachments { + background-color: #f4faff; + margin: 0 -0.9em; + position: relative; + top: 0.9em; + padding: 0.3em 0.9em; + border-top: 1px dotted #ccc; + border-top-color: rgba(0,0,0,0.2); + border-radius: 0 0 6px 6px; +} +.thread-body .attachments .filesize { + margin-left: 0.5em; +} +.thread-body .attachment-info { + margin-right: 10px; + display: inline-block; + width: 48%; +} +.thread-body .attachment-info .filename { + max-width: 80%; + max-width: calc(100% - 70px); +} +.label { + font-size: 11px; + padding: 1px 4px; + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + border-radius: 3px; + font-weight: bold; + line-height: 14px; + color: #ffffff; + vertical-align: baseline; + white-space: nowrap; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); + background-color: #999999; +} +.label-bare { + background-color: transparent; + background-color: rgba(0,0,0,0); + border: 1px solid #999999; + color: #999999; + text-shadow: none; +} +.thread-event { + padding: 0px 2px 15px; + margin-left: 60px; +} +.type-icon { + border-radius: 8px; + background-color: #f4f4f4; + padding: 4px 6px; + margin-right: 5px; + text-align: center; + display: inline-block; + font-size: 1.1em; + border: 1px solid #eee; + vertical-align: top; +} +.type-icon.dark { + border-color: #666; + background-color: #949494; +} +.thread-event img.avatar { + vertical-align: middle; + border-radius: 3px; + width: auto; + max-height: 24px; + margin: -3px 3px 0; +} +.thread-event .description { + margin-left: -30px; + padding-top: 6px; + padding-left: 30px; + display: inline-block; + width: 642px; + width: calc(100% - 95px); + line-height: 1.4em; +} +.thread-event .type-icon { + position:relative; +} +.thread-event .type-icon::after { + content: ""; + border: 16px solid white; + position: absolute; + top: -3px; + bottom: 0; + left: -3px; + right: 0; + z-index: -1; +} +.thread-entry::after { + content: ""; + border-bottom: 2px solid white; + display: block; +} +.thread-entry::before { + content: ""; + display: block; + border-top: 2px solid white; +} +#ticketThread::before { + border-left: 2px dotted #ddd; + border-bottom-color: rgba(0,0,0,0.1); + position: absolute; + margin-left: 74px; + z-index: -1; + content: ""; + top: 0; + bottom: 0; + right: 0; + left: 0; +} +#ticketThread { + z-index: 0; + position: relative; + border-bottom: 2px solid #ddd; + border-bottom-color: rgba(0,0,0,0.1); +} diff --git a/bootstrap.php b/bootstrap.php index a49d8dcd98530af5842123898776108b9411c606..e056f8a7f1259b8c8be078204e49f58676dcbcc6 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -98,7 +98,7 @@ class Bootstrap { define('TICKET_TABLE',$prefix.'ticket'); define('TICKET_CDATA_TABLE', $prefix.'ticket__cdata'); - define('TICKET_EVENT_TABLE',$prefix.'ticket_event'); + define('THREAD_EVENT_TABLE',$prefix.'thread_event'); define('THREAD_COLLABORATOR_TABLE', $prefix.'thread_collaborator'); define('TICKET_STATUS_TABLE', $prefix.'ticket_status'); define('TICKET_PRIORITY_TABLE',$prefix.'ticket_priority'); diff --git a/css/thread.css b/css/thread.css index 233f283158c5e8f98a67e1dad072bfac756e343d..4ac6615703681721278f9d2f8815c499d2a6aff9 100644 --- a/css/thread.css +++ b/css/thread.css @@ -30,7 +30,7 @@ color: #333333; background-color: #ffffff; margin: 0; - padding: 0.5em; + padding: 0.9em; } .thread-body a:focus { outline: thin dotted; diff --git a/include/ajax.reports.php b/include/ajax.reports.php index 6086e3cb0b65fb214c14dd141d874976c25c0916..7b2e4f450a74e0b780b8bbede6515d0382684152 100644 --- a/include/ajax.reports.php +++ b/include/ajax.reports.php @@ -90,7 +90,7 @@ class OverviewReportAjaxAPI extends AjaxController { COUNT(*)-COUNT(NULLIF(A1.state, "closed")) AS Closed, COUNT(*)-COUNT(NULLIF(A1.state, "reopened")) AS Reopened FROM '.$info['table'].' T1 - LEFT JOIN '.TICKET_EVENT_TABLE.' A1 + LEFT JOIN '.THREAD_EVENT_TABLE.' A1 ON (A1.'.$info['pk'].'=T1.'.$info['pk'].' AND NOT annulled AND (A1.timestamp BETWEEN '.$start.' AND '.$stop.')) @@ -190,7 +190,7 @@ class OverviewReportAjaxAPI extends AjaxController { list($start, $stop) = $this->_getDateRange(); # Fetch all types of events over the timeframe - $res = db_query('SELECT DISTINCT(state) FROM '.TICKET_EVENT_TABLE + $res = db_query('SELECT DISTINCT(state) FROM '.THREAD_EVENT_TABLE .' WHERE timestamp BETWEEN '.$start.' AND '.$stop .' ORDER BY 1'); $events = array(); @@ -200,7 +200,7 @@ class OverviewReportAjaxAPI extends AjaxController { # XXX: Implement annulled column from the %ticket_event table $res = db_query('SELECT state, DATE_FORMAT(timestamp, \'%Y-%m-%d\'), ' .'COUNT(ticket_id)' - .' FROM '.TICKET_EVENT_TABLE + .' FROM '.THREAD_EVENT_TABLE .' WHERE timestamp BETWEEN '.$start.' AND '.$stop .' AND NOT annulled' .' GROUP BY state, DATE_FORMAT(timestamp, \'%Y-%m-%d\')' diff --git a/include/ajax.tickets.php b/include/ajax.tickets.php index 1e95dde3ec1c033f51fb70b13456739aafea7cfb..d7f53279963b4272686c7d50a1cf19e0b67bb4bd 100644 --- a/include/ajax.tickets.php +++ b/include/ajax.tickets.php @@ -192,7 +192,6 @@ class TicketsAjaxAPI extends AjaxController { include STAFFINC_DIR . 'templates/ticket-preview.tmpl.php'; } - function viewUser($tid) { global $thisstaff; diff --git a/include/class.collaborator.php b/include/class.collaborator.php index 205b5200b151911c757611483fae9560f041bb27..6bc891e5fd9fd53d19789faac4de813fc2ab1dfc 100644 --- a/include/class.collaborator.php +++ b/include/class.collaborator.php @@ -35,8 +35,10 @@ implements EmailContact, ITicketUser { ); function __toString() { - return Format::htmlchars(sprintf('%s <%s>', $this->getName(), - $this->getEmail())); + return Format::htmlchars($this->toString()); + } + function toString() { + return sprintf('%s <%s>', $this->getName(), $this->getEmail()); } function getId() { diff --git a/include/class.dynamic_forms.php b/include/class.dynamic_forms.php index 43ae645c765a97524d7479e6995c5261319d522d..8bb94d81c792c4c407b6b0801de9a44f7dbae4a4 100644 --- a/include/class.dynamic_forms.php +++ b/include/class.dynamic_forms.php @@ -1168,6 +1168,23 @@ class DynamicFormEntry extends VerySimpleModel { return $this->getForm()->render($staff, $title, $options); } + function getChanges() { + $fields = array(); + foreach ($this->getAnswers() as $a) { + $field = $a->getField(); + if (!$field->hasData() || $field->isPresentationOnly()) + continue; + $val = $v = $field->to_database($field->getClean()); + if (is_array($val)) + $v = $val[0]; + if ($a->value == $v) + continue; + $before = $field->to_database($a->getValue()); + $fields[$field->get('id')] = array($before, $val); + } + return $fields; + } + /** * addMissingFields * @@ -1489,6 +1506,38 @@ class SelectionField extends FormField { return $value; } + // PHP 5.4 Move this to a trait + function whatChanged($before, $after) { + $before = (array) $before; + $after = (array) $after; + $added = array_diff($after, $before); + $deleted = array_diff($before, $after); + $added = array_map(array($this, 'display'), $added); + $deleted = array_map(array($this, 'display'), $deleted); + + if ($added && $deleted) { + $desc = sprintf( + __('added <strong>%1$s</strong> and removed <strong>%2$s</strong>'), + implode(', ', $added), implode(', ', $deleted)); + } + elseif ($added) { + $desc = sprintf( + __('added <strong>%1$s</strong>'), + implode(', ', $added)); + } + elseif ($deleted) { + $desc = sprintf( + __('removed <strong>%1$s</strong>'), + implode(', ', $deleted)); + } + else { + $desc = sprintf( + __('changed to <strong>%1$s</strong>'), + $this->display($after)); + } + return $desc; + } + function asVar($value, $id=false) { $values = $this->to_php($value, $id); if (is_array($values)) { diff --git a/include/class.export.php b/include/class.export.php index acdc333582c6d7d54b35c0fd17d4db5ccd5ec473..68b907282853cbf55b5ebde39f367230905d4ebc 100644 --- a/include/class.export.php +++ b/include/class.export.php @@ -345,7 +345,7 @@ class DatabaseExporter { FAQ_TOPIC_TABLE, FAQ_CATEGORY_TABLE, DRAFT_TABLE, CANNED_TABLE, TICKET_TABLE, ATTACHMENT_TABLE, THREAD_TABLE, THREAD_ENTRY_TABLE, THREAD_ENTRY_EMAIL_TABLE, - LOCK_TABLE, TICKET_EVENT_TABLE, TICKET_PRIORITY_TABLE, + LOCK_TABLE, THREAD_EVENT_TABLE, TICKET_PRIORITY_TABLE, EMAIL_TABLE, EMAIL_TEMPLATE_TABLE, EMAIL_TEMPLATE_GRP_TABLE, FILTER_TABLE, FILTER_RULE_TABLE, SLA_TABLE, API_KEY_TABLE, TIMEZONE_TABLE, SESSION_TABLE, PAGE_TABLE, diff --git a/include/class.format.php b/include/class.format.php index cd9b27f24657c5c95ff59699d9706912c38ae36b..33f6b4ebb9c055200fa14091e309120a9bc669a2 100644 --- a/include/class.format.php +++ b/include/class.format.php @@ -714,7 +714,7 @@ class Format { return $text; } - function relativeTime($to, $from=false) { + function relativeTime($to, $from=false, $granularity=1) { $timestamp = $to ?: Misc::gmtime(); if (gettype($timestamp) === 'string') $timestamp = strtotime($timestamp); @@ -724,6 +724,9 @@ class Format { $timeDiff = $from - $timestamp; $absTimeDiff = abs($timeDiff); + // Roll back to the nearest multiple of $granularity + $absTimeDiff -= $absTimeDiff % $granularity; + // within 2 seconds if ($absTimeDiff <= 2) { return $timeDiff >= 0 ? __('just now') : __('now'); @@ -758,7 +761,7 @@ class Format { $days2 = 2 * 86400; if ($absTimeDiff < $days2) { // XXX: yesterday / tomorrow? - return $absTimeDiff >= 0 ? __('1 day ago') : __('in 1 day'); + return $absTimeDiff >= 0 ? __('yesterday') : __('tomorrow'); } // within 29 days diff --git a/include/class.forms.php b/include/class.forms.php index 0b6da8f275f0f5a75fc08c3f618279bdd2aff058..73e89287bab0c1529558fdbe59fc3977ace94105 100644 --- a/include/class.forms.php +++ b/include/class.forms.php @@ -626,6 +626,19 @@ class FormField { return false; } + /** + * Describe the difference between the to two values. Note that the + * values should be passed through ::parse() or to_php() before + * utilizing this method. + */ + function whatChanged($before, $after) { + if ($before) + $desc = __('changed from <strong>%2$s</strong> to <strong>%1$s</strong>'); + else + $desc = __('set to <strong>%1$s</strong>'); + return sprintf($desc, $this->display($after), $this->display($before)); + } + /** * Convert the field data to something matchable by filtering. The * primary use of this is for ticket filtering. @@ -1312,6 +1325,37 @@ class ChoiceField extends FormField { return (string) $value; } + function whatChanged($before, $after) { + $B = (array) $before; + $A = (array) $after; + $added = array_diff($A, $B); + $deleted = array_diff($B, $A); + $added = array_map(array($this, 'display'), $added); + $deleted = array_map(array($this, 'display'), $deleted); + + if ($added && $deleted) { + $desc = sprintf( + __('added <strong>%1$s</strong> and removed <strong>%2$s</strong>'), + implode(', ', $added), implode(', ', $deleted)); + } + elseif ($added) { + $desc = sprintf( + __('added <strong>%1$s</strong>'), + implode(', ', $added)); + } + elseif ($deleted) { + $desc = sprintf( + __('removed <strong>%1$s</strong>'), + implode(', ', $deleted)); + } + else { + $desc = sprintf( + __('changed from <strong>%1$s</strong> to <strong>%2$s</strong>'), + $this->display($before), $this->display($after)); + } + return $desc; + } + /* Return criteria to which the choice should be filtered by */ @@ -1690,6 +1734,8 @@ class PriorityField extends ChoiceField { reset($id); $id = key($id); } + elseif (is_array($value)) + list($value, $id) = $value; elseif ($id === false) $id = $value; if ($id) diff --git a/include/class.orm.php b/include/class.orm.php index 57029871265b38038f32d5726038a724d39d3de0..b7250ee94a2a18a75cb2cb76e9991b6f673585bf 100644 --- a/include/class.orm.php +++ b/include/class.orm.php @@ -193,7 +193,8 @@ class VerySimpleModel { $fkey[$F ?: $_klas] = ($local[0] == "'") ? trim($local, "'") : $this->ht[$local]; } - $v = $this->ht[$field] = new InstrumentedList( + $manager = @$j['class'] ?: 'InstrumentedList'; + $v = $this->ht[$field] = new $manager( // Send Model, [Foriegn-Field => Local-Id] array($class, $fkey) ); diff --git a/include/class.staff.php b/include/class.staff.php index b573eaa6efcc7da49c0cc3a71b979a80cff9f40b..6389e0e3948eff71b3612b666ef185d2f677922c 100644 --- a/include/class.staff.php +++ b/include/class.staff.php @@ -243,6 +243,30 @@ implements AuthenticatedUser, EmailContact, TemplateVariable { function getEmail() { return $this->email; } + /** + * Get either a Gravatar URL or complete image tag for a specified email address. + * + * @param string $email The email address + * @param string $s Size in pixels, defaults to 80px [ 1 - 2048 ] + * @param string $d Default imageset to use [ 404 | mm | identicon | monsterid | wavatar ] + * @param string $r Maximum rating (inclusive) [ g | pg | r | x ] + * @param boole $img True to return a complete IMG tag False for just the URL + * @param array $atts Optional, additional key/value attributes to include in the IMG tag + * @return String containing either just a URL or a complete image tag + * @source http://gravatar.com/site/implement/images/php/ + */ + function get_gravatar($s = 80, $img = false, $atts = array(), $d = 'retro', $r = 'g' ) { + $url = '//www.gravatar.com/avatar/'; + $url .= md5( strtolower( $this->getEmail() ) ); + $url .= "?s=$s&d=$d&r=$r"; + if ( $img ) { + $url = '<img src="' . $url . '"'; + foreach ( $atts as $key => $val ) + $url .= ' ' . $key . '="' . $val . '"'; + $url .= ' />'; + } + return $url; + } function getUserName() { return $this->username; diff --git a/include/class.team.php b/include/class.team.php index 04427634a0bcce591e1e460737f34cd445e5e56b..f172f1ede5a97ab980a657d5c3d39b80107bc6ad 100644 --- a/include/class.team.php +++ b/include/class.team.php @@ -62,6 +62,9 @@ implements TemplateVariable { function getName() { return $this->name; } + function getLocalName() { + return $this->getLocal('name'); + } function getNumMembers() { return $this->members->count(); diff --git a/include/class.thread.php b/include/class.thread.php index 75d5a14a396bf1830b0708f89f6a6d0f7caeb72d..b228fd63e3d3de9f083b45314d5bebbcd4947dae 100644 --- a/include/class.thread.php +++ b/include/class.thread.php @@ -42,9 +42,16 @@ class Thread extends VerySimpleModel { 'entries' => array( 'reverse' => 'ThreadEntry.thread', ), + 'events' => array( + 'reverse' => 'ThreadEvent.thread', + 'class' => 'ThreadEvents', + ), ), ); + const MODE_STAFF = 1; + const MODE_CLIENT = 2; + var $_object; var $_collaborators; // Cache for collabs @@ -192,19 +199,25 @@ class Thread extends VerySimpleModel { return true; } // Render thread - function render($type=false) { + function render($type=false, $mode=self::MODE_STAFF) { $entries = $this->getEntries(); if ($type && is_array($type)) $entries->filter(array('type__in' => $type)); - include STAFFINC_DIR . 'templates/thread-entries.tmpl.php'; + $events = $this->getEvents(); + $inc = ($mode == self::MODE_STAFF) ? STAFFINC_DIR : CLIENTINC_DIR; + include $inc . 'templates/thread-entries.tmpl.php'; } function getEntry($id) { return ThreadEntry::lookup($id, $this->getId()); } + function getEvents() { + return $this->events; + } + /** * postEmail * @@ -1406,6 +1419,311 @@ implements TemplateVariable { RolePermission::register(/* @trans */ 'Tickets', ThreadEntry::getPermissions()); +class ThreadEvent extends VerySimpleModel { + static $meta = array( + 'table' => THREAD_EVENT_TABLE, + 'pk' => array('id'), + 'joins' => array( + // Originator of activity + 'agent' => array( + 'constraint' => array( + 'uid' => 'Staff.staff_id', + ), + 'null' => true, + ), + // Agent assignee + 'staff' => array( + 'constraint' => array( + 'staff_id' => 'Staff.staff_id', + ), + 'null' => true, + ), + 'team' => array( + 'constraint' => array( + 'team_id' => 'Team.team_id', + ), + 'null' => true, + ), + 'thread' => array( + 'constraint' => array('thread_id' => 'Thread.id'), + ), + 'user' => array( + 'constraint' => array( + 'uid' => 'User.id', + ), + 'null' => true, + ), + 'dept' => array( + 'constraint' => array( + 'dept_id' => 'Dept.id', + ), + 'null' => true, + ), + ), + ); + + // Valid events for database storage + const ASSIGNED = 'assigned'; + const CLOSED = 'closed'; + const CREATED = 'created'; + const COLLAB = 'collab'; + const EDITED = 'edited'; + const ERROR = 'error'; + const OVERDUE = 'overdue'; + const REOPENED = 'reopened'; + const STATUS = 'status'; + const TRANFERRED = 'transferred'; + const VIEWED = 'viewed'; + + const MODE_STAFF = 1; + const MODE_CLIENT = 2; + + var $_data; + + function getAvatar($size=16) { + if ($this->uid && $this->uid_type == 'S') + return $this->agent->get_gravatar($size); + if ($this->uid && $this->uid_type == 'U') + return $this->user->get_gravatar($size); + } + + function getUserName() { + if ($this->uid && $this->uid_type == 'S') + return $this->agent->getName(); + if ($this->uid && $this->uid_type == 'U') + return $this->user->getName(); + return $this->username; + } + + function getIcon() { + $icons = array( + 'assigned' => 'hand-right', + 'collab' => 'group', + 'created' => 'magic', + 'overdue' => 'time', + 'transferred' => 'share-alt', + 'edited' => 'pencil', + ); + return @$icons[$this->state] ?: 'chevron-sign-right'; + } + + function getDescription($mode=self::MODE_STAFF) { + static $descs; + if (!isset($descs)) + $descs = array( + 'assigned' => __('Assignee changed by <b>{username}</b> to <strong>{assignees}</strong> {timestamp}'), + 'assigned:staff' => __('<b>{username}</b> assigned this to <strong>{<Staff>data.staff}</strong> {timestamp}'), + 'assigned:team' => __('<b>{username}</b> assigned this to <strong>{<Team>data.team}</strong> {timestamp}'), + 'assigned:claim' => __('<b>{username}</b> claimed this {timestamp}'), + 'collab:org' => __('Collaborators for {<Organization>data.org} organization added'), + 'collab:del' => function($evt) { + $data = $evt->getData(); + $base = __('<b>{username}</b> removed %s from the collaborators.'); + return $data['del'] + ? Format::htmlchars(sprintf($base, implode(', ', $data['del']))) + : 'somebody'; + }, + 'collab:add' => function($evt) { + $data = $evt->getData(); + $base = __('<b>{username}</b> added <strong>%s</strong> as collaborators {timestamp}'); + $collabs = array(); + if ($data['add']) { + foreach ($data['add'] as $c) { + $collabs[] = Format::htmlchars($c); + } + } + return $collabs + ? sprintf($base, implode(', ', $collabs)) + : 'somebody'; + }, + 'created' => __('Created by <b>{username}</b> {timestamp}'), + 'closed' => __('Closed by <b>{username}</b> {timestamp}'), + 'reopened' => __('Reopened by <b>{username}</b> {timestamp}'), + 'edited:owner' => __('<b>{username}</b> changed ownership to {<User>data.owner} {timestamp}'), + 'edited:status' => __('<b>{username}</b> changed the status to <strong>{<TicketStatus>data.status}</strong> {timestamp}'), + 'overdue' => __('Flagged as overdue by the system {timestamp}'), + 'transferred' => __('<b>{username}</b> transferred this to <strong>{dept}</strong> {timestamp}'), + 'edited:fields' => function($evt) use ($mode) { + $base = __('Updated by <b>{username}</b> {timestamp} — %s'); + $data = $evt->getData(); + $fields = $changes = array(); + foreach (DynamicFormField::objects()->filter(array( + 'id__in' => array_keys($data['fields']) + )) as $F) { + $fields[$F->id] = $F; + } + foreach ($data['fields'] as $id=>$f) { + $field = $fields[$id]; + if ($mode == self::MODE_CLIENT && !$field->isVisibleToUsers()) + continue; + list($old, $new) = $f; + $impl = $field->getImpl($field); + $before = $impl->to_php($old); + $after = $impl->to_php($new); + $changes[] = sprintf('<strong>%s</strong> %s', + $field->getLocal('label'), $impl->whatChanged($before, $after)); + } + if (!$changes) + return ''; + return sprintf($base, implode(', ', $changes)); + }, + ); + $self = $this; + $data = $this->getData(); + $state = $this->state; + if (is_array($data)) { + foreach (array_keys($data) as $k) + if (isset($descs[$state . ':' . $k])) + $state .= ':' . $k; + } + $description = $descs[$state]; + if (is_callable($description)) + $description = $description($this); + + return preg_replace_callback('/\{(<(?P<type>([^>]+))>)?(?P<key>[^}.]+)(\.(?P<data>[^}]+))?\}/', + function ($m) use ($self) { + switch ($m['key']) { + case 'assignees': + $assignees = array(); + if ($S = $this->staff) { + $url = $S->get_gravatar(16); + $assignees[] = + "<img class=\"avatar\" src=\"{$url}\"> ".$S->getName(); + } + if ($T = $this->team) { + $assignees[] = $T->getLocalName(); + } + return implode('/', $assignees); + case 'username': + $name = $self->getUserName(); + if ($url = $self->getAvatar()) + $name = "<img class=\"avatar\" src=\"{$url}\"> ".$name; + return $name; + case 'timestamp': + return sprintf('<time class="relative" datetime="%s" title="%s">%s</time>', + date(DateTime::W3C, Misc::db2gmtime($self->timestamp)), + Format::daydatetime($self->timestamp), + Format::relativeTime(Misc::db2gmtime($self->timestamp)) + ); + case 'agent': + $st = $this->agent; + if ($url = $self->getAvatar()) + $name = "<img class=\"avatar\" src=\"{$url}\"> ".$name; + case 'dept': + if ($dept = $this->getDept()) + return $dept->getLocalName(); + case 'data': + $val = $self->getData($m['data']); + if ($m['type'] && class_exists($m['type'])) + $val = $m['type']::lookup($val); + return (string) $val; + } + return $m[0]; + }, + $description + ); + } + + function getDept() { + return $this->dept; + } + + function getData($key=false) { + if (!isset($this->_data)) + $this->_data = JsonDataParser::decode($this->data); + return ($key) ? @$this->_data[$key] : $this->_data; + } + + function render($mode) { + $inc = ($mode == self::MODE_STAFF) ? STAFFINC_DIR : CLIENTINC_DIR; + $event = $this; + include $inc . 'templates/thread-event.tmpl.php'; + } + + static function create($ht=false) { + $inst = parent::create($ht); + $inst->timestamp = SqlFunction::NOW(); + + global $thisstaff, $thisclient; + if ($thisstaff) { + $inst->uid_type = 'S'; + $inst->uid = $thisstaff->getId(); + } + else if ($thisclient) { + $inst->uid_type = 'U'; + $inst->uid = $thisclient->getId(); + } + + return $inst; + } + + static function forTicket($ticket, $state) { + $inst = static::create(array( + 'staff_id' => $ticket->getStaffId(), + 'team_id' => $ticket->getTeamId(), + 'dept_id' => $ticket->getDeptId(), + 'topic_id' => $ticket->getTopicId(), + )); + if (!isset($inst->uid_type) && $state == self::CREATED) { + $inst->uid_type = 'U'; + $inst->uid = $ticket->getOwnerId(); + } + return $inst; + } +} + +class ThreadEvents extends InstrumentedList { + function annul($event) { + $this->queryset + ->filter(array('state' => $event)) + ->update(array('annulled' => 1)); + } + + function log($object, $state, $data=null, $annul=null, $username=null) { + if ($object instanceof Ticket) + $event = ThreadEvent::forTicket($object, $state); + else + $event = ThreadEvent::create(); + + # Annul previous entries if requested (for instance, reopening a + # ticket will annul an 'closed' entry). This will be useful to + # easily prevent repeated statistics. + if ($annul) { + $this->annul($annul); + } + + if ($username === null) { + if ($thisstaff) { + $username = $thisstaff->getUserName(); + } + else if ($thisclient) { + if ($thisclient->hasAccount) + $username = $thisclient->getAccount()->getUserName(); + if (!$username) + $username = $thisclient->getEmail(); + } + else { + # XXX: Security Violation ? + $username = 'SYSTEM'; + } + } + $event->username = $username; + $event->state = $state; + + if ($data) { + if (is_array($data)) + $data = JsonDataEncoder::encode($data); + if (!is_string($data)) + throw new InvalidArgumentException('Data must be string or array'); + $event->data = $data; + } + + $this->add($event); + + // Save event immediately + return $event->save(); + } +} class ThreadEntryBody /* extends SplString */ { diff --git a/include/class.ticket.php b/include/class.ticket.php index 605758114102bcc1dfe959abec689b933a8e4fb9..9f53f0a29dc6b8391dbb37c11dfb55266168a141 100644 --- a/include/class.ticket.php +++ b/include/class.ticket.php @@ -943,6 +943,8 @@ implements RestrictedAccess, Threadable, TemplateVariable { $this->collaborators = null; $this->recipients = null; + $this->logEvent('collab', array('add' => array($c->toString()))); + return $c; } @@ -959,11 +961,10 @@ implements RestrictedAccess, Threadable, TemplateVariable { if (($c=Collaborator::lookup($cid)) && $c->getTicketId() == $this->getId() && $c->delete()) - $collabs[] = $c; + $collabs[] = (string) $c; } - $this->logNote(_S('Collaborators Removed'), - implode("<br>", $collabs), $thisstaff, false); + $this->logEvent('collab', array('del' => $collabs)); } //statuses @@ -1164,6 +1165,7 @@ implements RestrictedAccess, Threadable, TemplateVariable { } } + $hadStatus = $this->getStatusId(); if ($this->getStatusId() == $status->getId()) return true; @@ -1196,7 +1198,7 @@ implements RestrictedAccess, Threadable, TemplateVariable { if ($this->isClosed()) { $sql .= ',closed=NULL, lastupdate=NOW(), reopened=NOW() '; $ecb = function ($t) { - $t->logEvent('reopened', 'closed'); + $t->logEvent('reopened', false, 'closed'); }; } @@ -1228,12 +1230,15 @@ implements RestrictedAccess, Threadable, TemplateVariable { $note .= sprintf('<hr>%s', $comments); // Send out alerts if comments are included $alert = true; + $this->logNote(__('Status Changed'), $note, $thisstaff, $alert); } - - $this->logNote(__('Status Changed'), $note, $thisstaff, $alert); } // Log events via callback - if ($ecb) $ecb($this); + if ($ecb) + $ecb($this); + elseif ($hadStatus) + // Don't log the initial status change + $this->logEvent('edited', array('status' => $status->getId())); return true; } @@ -1662,13 +1667,16 @@ implements RestrictedAccess, Threadable, TemplateVariable { $this->reload(); + $user_comments = (bool) $comments; $comments = $comments ?: _S('Ticket assignment'); $assigner = $thisstaff ?: _S('SYSTEM (Auto Assignment)'); //Log an internal note - no alerts on the internal note. - $note = $this->logNote( - sprintf(_S('Ticket Assigned to %s'), $assignee->getName()), - $comments, $assigner, false); + if ($user_comments) { + $note = $this->logNote( + sprintf(_S('Ticket Assigned to %s'), $assignee->getName()), + $comments, $assigner, false); + } //See if we need to send alerts if(!$alert || !$cfg->alertONAssignment()) return true; //No alerts! @@ -1971,8 +1979,11 @@ implements RestrictedAccess, Threadable, TemplateVariable { /*** log the transfer comments as internal note - with alerts disabled - ***/ $title=sprintf(_S('Ticket transferred from %1$s to %2$s'), $currentDept, $this->getDeptName()); - $comments=$comments?$comments:$title; - $note = $this->logNote($title, $comments, $thisstaff, false); + + if ($comments) { + $note = $this->logNote($title, $comments, $thisstaff, false); + } + $comments = $comments ?: $title; $this->logEvent('transferred'); @@ -2004,11 +2015,13 @@ implements RestrictedAccess, Threadable, TemplateVariable { if($cfg->alertDeptManagerONTransfer() && $dept && ($manager=$dept->getManager())) $recipients[]= $manager; - $sentlist=array(); - $options = array( - 'inreplyto'=>$note->getEmailMessageId(), - 'references'=>$note->getEmailReferences(), - 'thread'=>$note); + $sentlist = $options = array(); + if ($note) { + $options += array( + 'inreplyto'=>$note->getEmailMessageId(), + 'references'=>$note->getEmailReferences(), + 'thread'=>$note); + } foreach( $recipients as $k=>$staff) { if(!is_object($staff) || !$staff->isAvailable() || in_array($staff->getEmail(), $sentlist)) continue; $alert = $this->replaceVars($msg, array('recipient' => $staff)); @@ -2030,9 +2043,7 @@ implements RestrictedAccess, Threadable, TemplateVariable { if ($dept->assignMembersOnly() && !$dept->isMember($thisstaff)) return false; - $comments = sprintf(_S('Ticket claimed by %s'), $thisstaff->getName()); - - return $this->assignToStaff($thisstaff->getId(), $comments, false); + return $this->assignToStaff($thisstaff->getId(), null, false); } function assignToStaff($staff, $note, $alert=true) { @@ -2044,7 +2055,14 @@ implements RestrictedAccess, Threadable, TemplateVariable { return false; $this->onAssign($staff, $note, $alert); - $this->logEvent('assigned'); + + global $thisstaff; + $data = array(); + if ($staff->getId() == $thisstaff->getId()) + $data['claim'] = true; + else + $data['staff'] = $staff->getId(); + $this->logEvent('assigned', $data); return true; } @@ -2063,7 +2081,7 @@ implements RestrictedAccess, Threadable, TemplateVariable { $this->setStaffId(0); $this->onAssign($team, $note, $alert); - $this->logEvent('assigned'); + $this->logEvent('assigned', array('team' => $team->getId())); return true; } @@ -2134,18 +2152,15 @@ implements RestrictedAccess, Threadable, TemplateVariable { $this->collaborators = null; $this->recipients = null; - //Log an internal note - $note = sprintf(_S('%s changed ticket ownership to %s'), - $thisstaff->getName(), $user->getName()); - - //Remove the new owner from list of collaborators + // Remove the new owner from list of collaborators $c = Collaborator::lookup(array( - 'user_id' => $user->getId(), - 'thread_id' => $this->getThreadId())); - if ($c && $c->delete()) - $note.= ' '._S('(removed as collaborator)'); + 'user_id' => $user->getId(), + 'thread_id' => $this->getThreadId() + )); + if ($c) + $c->delete(); - $this->logNote('Ticket ownership changed', $note); + $this->logEvent('edited', array('owner' => $user->getId())); return true; } @@ -2184,18 +2199,11 @@ implements RestrictedAccess, Threadable, TemplateVariable { if (($user=User::fromVars($recipient))) if ($c=$this->addCollaborator($user, $info, $errors)) - $collabs[] = sprintf('%s%s', - (string) $c, - $recipient['source'] - ? " ".sprintf(_S('via %s'), $recipient['source']) - : '' - ); + $collabs[] = array((string)$c, $recipient['source']); } //TODO: Can collaborators add others? if ($collabs) { - //TODO: Change EndUser to name of user. - $this->logNote(_S('Collaborators added by end user'), - implode("<br>", $collabs), _S('End User'), false); + $this->logEvent('collab', array('add' => $collabs)); } } @@ -2411,31 +2419,8 @@ implements RestrictedAccess, Threadable, TemplateVariable { } // History log -- used for statistics generation (pretty reports) - function logEvent($state, $annul=null, $staff=null) { - global $thisstaff; - - if ($staff === null) { - if ($thisstaff) $staff=$thisstaff->getUserName(); - else $staff='SYSTEM'; # XXX: Security Violation ? - } - # Annul previous entries if requested (for instance, reopening a - # ticket will annul an 'closed' entry). This will be useful to - # easily prevent repeated statistics. - if ($annul) { - db_query('UPDATE '.TICKET_EVENT_TABLE.' SET annulled=1' - .' WHERE ticket_id='.db_input($this->getId()) - .' AND state='.db_input($annul)); - } - - return db_query('INSERT INTO '.TICKET_EVENT_TABLE - .' SET ticket_id='.db_input($this->getId()) - .', staff_id='.db_input($this->getStaffId()) - .', team_id='.db_input($this->getTeamId()) - .', dept_id='.db_input($this->getDeptId()) - .', topic_id='.db_input($this->getTopicId()) - .', timestamp=NOW(), state='.db_input($state) - .', staff='.db_input($staff)) - && db_affected_rows() == 1; + function logEvent($state, $data=null, $annul=null, $staff=null) { + $this->getThread()->getEvents()->log($this, $state, $data, $annul, $staff); } //Insert Internal Notes @@ -2593,7 +2578,6 @@ implements RestrictedAccess, Threadable, TemplateVariable { $fields['slaId'] = array('type'=>'int', 'required'=>0, 'error'=>__('Select a valid SLA')); $fields['duedate'] = array('type'=>'date', 'required'=>0, 'error'=>__('Invalid date format - must be MM/DD/YY')); - $fields['note'] = array('type'=>'text', 'required'=>1, 'error'=>__('A reason for the update is required')); $fields['user_id'] = array('type'=>'int', 'required'=>0, 'error'=>__('Invalid user-id')); if(!Validator::process($fields, $vars, $errors) && !$errors['err']) @@ -2644,16 +2628,16 @@ implements RestrictedAccess, Threadable, TemplateVariable { if(!db_query($sql) || !db_affected_rows()) return false; - if(!$vars['note']) - $vars['note']=sprintf(_S('Ticket details updated by %s'), $thisstaff->getName()); - - $this->logNote(_S('Ticket Updated'), $vars['note'], $thisstaff); + if ($vars['note']) + $this->logNote(_S('Ticket Updated'), $vars['note'], $thisstaff); // Decide if we need to keep the just selected SLA $keepSLA = ($this->getSLAId() != $vars['slaId']); // Update dynamic meta-data + $changes = array(); foreach ($forms as $f) { + $changes += $f->getChanges(); // Drop deleted forms $idx = array_search($f->getId(), $vars['forms']); if ($idx === false) { @@ -2665,6 +2649,9 @@ implements RestrictedAccess, Threadable, TemplateVariable { } } + if ($changes) + $this->logEvent('edited', array('fields' => $changes)); + // Reload the ticket so we can do further checking $this->reload(); @@ -3262,10 +3249,7 @@ implements RestrictedAccess, Threadable, TemplateVariable { } //TODO: Can collaborators add others? if ($collabs) { - //TODO: Change EndUser to name of user. - $ticket->logNote(sprintf(_S('Collaborators for %s organization added'), - $org->getName()), - implode("<br>", $collabs), $org->getName(), false); + $ticket->logEvent('collab', array('org' => $org->getId())); } } @@ -3418,11 +3402,6 @@ implements RestrictedAccess, Threadable, TemplateVariable { } $ticket->logNote(_S('New Ticket'), $vars['note'], $thisstaff, false); } - else { - // Not assignment and no internal note - log activity - $ticket->logActivity(_S('New Ticket by Agent'), - sprintf(_S('Ticket created by agent - %s'), $thisstaff->getName())); - } $ticket->reload(); @@ -3493,9 +3472,8 @@ implements RestrictedAccess, Threadable, TemplateVariable { if(($res=db_query($sql)) && db_num_rows($res)) { while(list($id)=db_fetch_row($res)) { - if(($ticket=Ticket::lookup($id)) && $ticket->markOverdue()) - $ticket->logActivity(_S('Ticket Marked Overdue'), - _S('Ticket flagged as overdue by the system.')); + if ($ticket=Ticket::lookup($id)) + $ticket->markOverdue(); } } else { //TODO: Trigger escalation on already overdue tickets - make sure last overdue event > grace_period. diff --git a/include/class.user.php b/include/class.user.php index 1b4e309d8af92aae4aef4e277ae23df2c0ffb894..fee4c7a91566621a8d9bd37e0cfdfb9a701c53da 100644 --- a/include/class.user.php +++ b/include/class.user.php @@ -39,7 +39,7 @@ class UserModel extends VerySimpleModel { static $meta = array( 'table' => USER_TABLE, 'pk' => array('id'), - 'select_related' => array('default_email'), + 'select_related' => array('account', 'default_email'), 'joins' => array( 'emails' => array( 'reverse' => 'UserEmailModel.user', @@ -123,6 +123,9 @@ class UserModel extends VerySimpleModel { return $this->default_email; } + function hasAccount() { + return !is_null($this->account); + } function getAccount() { return $this->account; } @@ -264,6 +267,30 @@ implements TemplateVariable { function getEmail() { return new EmailAddress($this->default_email->address); } + /** + * Get either a Gravatar URL or complete image tag for a specified email address. + * + * @param string $email The email address + * @param string $s Size in pixels, defaults to 80px [ 1 - 2048 ] + * @param string $d Default imageset to use [ 404 | mm | identicon | monsterid | wavatar ] + * @param string $r Maximum rating (inclusive) [ g | pg | r | x ] + * @param boole $img True to return a complete IMG tag False for just the URL + * @param array $atts Optional, additional key/value attributes to include in the IMG tag + * @return String containing either just a URL or a complete image tag + * @source http://gravatar.com/site/implement/images/php/ + */ + function get_gravatar($s = 80, $img = false, $atts = array(), $d = 'retro', $r = 'g' ) { + $url = '//www.gravatar.com/avatar/'; + $url .= md5( strtolower( $this->default_email->address ) ); + $url .= "?s=$s&d=$d&r=$r"; + if ( $img ) { + $url = '<img src="' . $url . '"'; + foreach ( $atts as $key => $val ) + $url .= ' ' . $key . '="' . $val . '"'; + $url .= ' />'; + } + return $url; + } function getFullName() { return $this->name; diff --git a/include/client/templates/thread-entries.tmpl.php b/include/client/templates/thread-entries.tmpl.php new file mode 100644 index 0000000000000000000000000000000000000000..d031182cad2fed9947e5bf75a953b05fb81b0374 --- /dev/null +++ b/include/client/templates/thread-entries.tmpl.php @@ -0,0 +1,49 @@ +<?php +$events = $events + ->filter(array('state__in' => array('created', 'closed', 'reopened', 'edited', 'collab'))) + ->order_by('id'); +$events = $events->getIterator(); +$events->rewind(); +$event = $events->current(); + +if (count($entries)) { + // Go through all the entries and bucket them by time frame + $buckets = array(); + $rel = 0; + foreach ($entries as $i=>$E) { + // First item _always_ shows up + if ($i != 0) + // Set relative time resolution to 12 hours + $rel = Format::relativeTime(Misc::db2gmtime($E->created, false, 43200)); + $buckets[$rel][] = $E; + } + + // Go back through the entries and render them on the page + $i = 0; + foreach ($buckets as $rel=>$entries) { + // TODO: Consider adding a date boundary to indicate significant + // changes in dates between thread items. + foreach ($entries as $entry) { + // Emit all events prior to this entry + while ($event && $event->timestamp <= $entry->created) { + $event->render(ThreadEvent::MODE_CLIENT); + $events->next(); + $event = $events->current(); + } + include 'thread-entry.tmpl.php'; + } + $i++; + } +} + +// Emit all other events +while ($event) { + $event->render(ThreadEvent::MODE_CLIENT); + $events->next(); + $event = $events->current(); +} + +// This should never happen +if (count($entries) + count($events) == 0) { + echo '<p><em>'.__('No entries have been posted to this thread.').'</em></p>'; +} diff --git a/include/client/templates/thread-entry.tmpl.php b/include/client/templates/thread-entry.tmpl.php new file mode 100644 index 0000000000000000000000000000000000000000..fe9f686e8201d0e1ee66551dd0d29e55c6ec7039 --- /dev/null +++ b/include/client/templates/thread-entry.tmpl.php @@ -0,0 +1,91 @@ +<?php +$entryTypes = array('M'=>'message', 'R'=>'response', 'N'=>'note'); +$user = $entry->getUser() ?: $entry->getStaff(); +$name = $user ? $user->getName() : $entry->poster; +$avatar = ''; +if ($user && ($url = $user->get_gravatar(48))) + $avatar = "<img class=\"avatar\" src=\"{$url}\"> "; +?> + +<div class="thread-entry <?php echo $entryTypes[$entry->type]; ?> <?php if ($avatar) echo 'avatar'; ?>"> +<?php if ($avatar) { ?> + <span class="<?php echo ($entry->type == 'M') ? 'pull-left' : 'pull-right'; ?> avatar"> +<?php echo $avatar; ?> + </span> +<?php } ?> + <div class="header"> + <div class="pull-right"> +<?php if ($entry->hasActions()) { + $actions = $entry->getActions(); ?> + <span class="muted-button pull-right" data-dropdown="#entry-action-more-<?php echo $entry->getId(); ?>"> + <i class="icon-caret-down"></i> + </span> + <div id="entry-action-more-<?php echo $entry->getId(); ?>" class="action-dropdown anchor-right"> + <ul class="title"> +<?php foreach ($actions as $group => $list) { + foreach ($list as $id => $action) { ?> + <li> + <a class="no-pjax" href="#" onclick="javascript: + <?php echo str_replace('"', '\\"', $action->getJsStub()); ?>; return false;"> + <i class="<?php echo $action->getIcon(); ?>"></i> <?php + echo $action->getName(); + ?></a></li> +<?php } + } ?> + </ul> + </div> +<?php } ?> + <span style="vertical-align:middle;" class="textra"> + <?php if ($entry->flags & ThreadEntry::FLAG_EDITED) { ?> + <span class="label label-bare" title="<?php + echo sprintf(__('Edited on %s by %s'), Format::datetime($entry->updated), 'You'); + ?>"><?php echo __('Edited'); ?></span> + <?php } ?> + </span> + </div> +<?php + echo sprintf(__('<b>%s</b> posted %s'), $name, + sprintf('<time class="relative" datetime="%s" title="%s">%s</time>', + date(DateTime::W3C, Misc::db2gmtime($entry->created)), + Format::daydatetime($entry->created), + Format::relativeTime(Misc::db2gmtime($entry->created)) + ) + ); ?> + <span style="max-width:500px" class="faded title truncate"><?php + echo $entry->title; ?></span> + </span> + </div> + <div class="thread-body" id="thread-id-<?php echo $entry->getId(); ?>"> + <div><?php echo $entry->getBody()->toHtml(); ?></div> +<?php + if ($entry->has_attachments) { ?> + <div class="attachments"><?php + foreach ($entry->attachments as $A) { + if ($A->inline) + continue; + $size = ''; + if ($A->file->size) + $size = sprintf('<small class="filesize faded">%s</small>', Format::file_size($A->file->size)); +?> + <span class="attachment-info"> + <i class="icon-paperclip icon-flip-horizontal"></i> + <a class="no-pjax truncate filename" href="<?php echo $A->file->getDownloadUrl(); + ?>" download="<?php echo Format::htmlchars($A->file->name); ?>" + target="_blank"><?php echo Format::htmlchars($A->file->name); + ?></a><?php echo $size;?> + </span> +<?php } ?> + </div> +<?php } ?> + </div> +<?php + if ($urls = $entry->getAttachmentUrls()) { ?> + <script type="text/javascript"> + $('#thread-id-<?php echo $entry->getId(); ?>') + .data('urls', <?php + echo JsonDataEncoder::encode($urls); ?>) + .data('id', <?php echo $entry->getId(); ?>); + </script> +<?php + } ?> +</div> diff --git a/include/client/templates/thread-event.tmpl.php b/include/client/templates/thread-event.tmpl.php new file mode 100644 index 0000000000000000000000000000000000000000..42fd8027e0024324e15407e0cd4add7c0b69dc83 --- /dev/null +++ b/include/client/templates/thread-event.tmpl.php @@ -0,0 +1,11 @@ +<?php +$desc = $event->getDescription(ThreadEvent::MODE_CLIENT); +if (!$desc) + return; +?> +<div class="thread-event <?php if ($event->uid) echo 'action'; ?>"> + <span class="type-icon"> + <i class="faded icon-<?php echo $event->getIcon(); ?>"></i> + </span> + <span class="faded description"><?php echo $desc; ?></span> +</div> diff --git a/include/client/view.inc.php b/include/client/view.inc.php index 4f96855d7fe0822a3c14c7a27877d9c26bd601b3..f11afbabf28d8c5fb1b6be76386fc3d853905e45 100644 --- a/include/client/view.inc.php +++ b/include/client/view.inc.php @@ -126,55 +126,13 @@ foreach (DynamicFormEntry::forTicket($ticket->getId()) as $form) { </tr> </table> <br> + <div id="ticketThread"> <?php -if($ticket->getThreadCount() && ($thread=$ticket->getClientThread())) { - $threadType=array('M' => 'message', 'R' => 'response'); - foreach($thread as $entry) { - - //Making sure internal notes are not displayed due to backend MISTAKES! - if(!$threadType[$entry->type]) continue; - $poster = $entry->poster; - if($entry->type=='R' && ($cfg->hideStaffName() || !$entry->staff_id)) - $poster = ' '; - ?> - <table class="thread-entry <?php echo $threadType[$entry->type]; ?>" cellspacing="0" cellpadding="1" width="800" border="0"> - <tr><th><div> -<?php echo Format::datetime($entry->created); ?> - <span class="textra"></span> - <span><?php echo $poster; ?></span> - </div> - </th></tr> - <tr><td class="thread-body"><div><?php echo Format::clickableurls($entry->getBody()->toHtml()); ?></div></td></tr> - <?php - $urls = null; - if ($entry->has_attachments - && ($urls = $entry->getAttachmentUrls())) { ?> - <tr> - <td class="info"><?php - foreach ($entry->attachments as $A) { - if ($A->inline) continue; - $size = ''; - if ($A->file->size) - $size = sprintf('<em>(%s)</em>', - Format::file_size($A->file->size)); -?> - <i class="icon-paperclip"></i> - <a class="no-pjax" href="<?php echo $A->file->getDownloadUrl(); - ?>" download="<?php echo Format::htmlchars($A->file->name); ?>" - target="_blank"> - <?php echo Format::htmlchars($A->file->name); - ?></a><?php echo $size;?> -<?php } ?> - </td> - </tr> -<?php } ?> - </table> - <?php - } -} + $ticket->getThread()->render(array('M', 'R'), Thread::MODE_CLIENT); ?> </div> + <div class="clear" style="padding-bottom:10px;"></div> <?php if($errors['err']) { ?> <div id="msg_error"><?php echo $errors['err']; ?></div> diff --git a/include/staff/faq-category.inc.php b/include/staff/faq-category.inc.php index afc5194bb7c08ebfe4ea4caeed74072a5b434db4..b6287bcb9eb3779a9aab6aa5a49dc244683e1856 100644 --- a/include/staff/faq-category.inc.php +++ b/include/staff/faq-category.inc.php @@ -11,7 +11,7 @@ if(!defined('OSTSTAFFINC') || !$category || !$thisstaff) die('Access Denied'); <div> <strong><?php echo $category->getName() ?></strong> <span>(<?php echo $category->isPublic()?__('Public'):__('Internal'); ?>)</span> - <time> <?php echo __('Last updated').' '. Format::daydatetime($category->getUpdateDate()); ?></time> + <time class="faq"> <?php echo __('Last updated').' '. Format::daydatetime($category->getUpdateDate()); ?></time> </div> <div class="cat-desc"> <?php echo Format::display($category->getDescription()); ?> diff --git a/include/staff/templates/thread-entries.tmpl.php b/include/staff/templates/thread-entries.tmpl.php index 2e7da2c389cf2b276b139a5573e040fb0e6590a9..f15ea1e061997647ba4ec246a630008f412a0c8e 100644 --- a/include/staff/templates/thread-entries.tmpl.php +++ b/include/staff/templates/thread-entries.tmpl.php @@ -1,90 +1,47 @@ <?php -$entryTypes = array('M'=>'message', 'R'=>'response', 'N'=>'note'); -if ($entries) { - foreach ($entries as $entry) { ?> - <table class="thread-entry <?php echo $entryTypes[$entry->type]; ?>" cellspacing="0" cellpadding="1" width="940" border="0"> - <tr> - <th colspan="4" width="100%"> - <div> - <span class="pull-left"> - <span style="display:inline-block"><?php - echo Format::datetime($entry->created);?></span> - <span style="display:inline-block;padding:0 1em;max-width: 500px" class="faded title truncate"><?php - echo $entry->title; ?></span> - </span> - <div class="pull-right"> -<?php if ($entry->hasActions()) { - $actions = $entry->getActions(); ?> - <span class="action-button pull-right" data-dropdown="#entry-action-more-<?php echo $entry->getId(); ?>"> - <i class="icon-caret-down"></i> - <span ><i class="icon-cog"></i></span> - </span> - <div id="entry-action-more-<?php echo $entry->getId(); ?>" class="action-dropdown anchor-right"> - <ul class="title"> -<?php foreach ($actions as $group => $list) { - foreach ($list as $id => $action) { ?> - <li> - <a class="no-pjax" href="#" onclick="javascript: - <?php echo str_replace('"', '\\"', $action->getJsStub()); ?>; return false;"> - <i class="<?php echo $action->getIcon(); ?>"></i> <?php - echo $action->getName(); - ?></a></li> -<?php } - } ?> - </ul> - </div> -<?php } ?> - <span style="vertical-align:middle"> - <span style="vertical-align:middle;" class="textra"> - <?php if ($entry->flags & ThreadEntry::FLAG_EDITED) { ?> - <span class="label label-bare" title="<?php - echo sprintf(__('Edited on %s by %s'), Format::datetime($entry->updated), 'You'); - ?>"><?php echo __('Edited'); ?></span> - <?php } ?> - </span> - <span style="vertical-align:middle;" - class="tmeta faded title"><?php - echo Format::htmlchars($entry->getName()); ?></span> - </span> - </div> - </th> - </tr> - <tr><td colspan="4" class="thread-body" id="thread-id-<?php - echo $entry->getId(); ?>"><div><?php - echo $entry->getBody()->toHtml(); ?></div></td></tr> - <?php - $urls = null; - if ($entry->has_attachments - && ($urls = $entry->getAttachmentUrls())) { ?> - <tr> - <td class="info" colspan="4"><?php - foreach ($entry->attachments as $A) { - if ($A->inline) continue; - $size = ''; - if ($A->file->size) - $size = sprintf('<em>(%s)</em>', - Format::file_size($A->file->size)); -?> - <a class="Icon file no-pjax" href="<?php echo $A->file->getDownloadUrl(); - ?>" download="<?php echo Format::htmlchars($A->file->name); ?>" - target="_blank"><?php echo Format::htmlchars($A->file->name); - ?></a><?php echo $size;?> -<?php } ?> - </td> - </tr> <?php +$events = $events->order_by('id'); +$events = $events->getIterator(); +$events->rewind(); +$event = $events->current(); + +if (count($entries)) { + // Go through all the entries and bucket them by time frame + $buckets = array(); + $rel = 0; + foreach ($entries as $i=>$E) { + // First item _always_ shows up + if ($i != 0) + // Set relative time resolution to 12 hours + $rel = Format::relativeTime(Misc::db2gmtime($E->created, false, 43200)); + $buckets[$rel][] = $E; + } + + // Go back through the entries and render them on the page + $i = 0; + foreach ($buckets as $rel=>$entries) { + // TODO: Consider adding a date boundary to indicate significant + // changes in dates between thread items. + foreach ($entries as $entry) { + // Emit all events prior to this entry + while ($event && $event->timestamp <= $entry->created) { + $event->render(ThreadEvent::MODE_STAFF); + $events->next(); + $event = $events->current(); + } + include STAFFINC_DIR . 'templates/thread-entry.tmpl.php'; } - if ($urls) { ?> - <script type="text/javascript"> - $('#thread-id-<?php echo $entry->getId(); ?>') - .data('urls', <?php - echo JsonDataEncoder::encode($urls); ?>) - .data('id', <?php echo $entry->getId(); ?>); - </script> -<?php - } ?> - </table> - <?php + $i++; } -} else { +} + +// Emit all other events +while ($event) { + $event->render(ThreadEvent::MODE_STAFF); + $events->next(); + $event = $events->current(); +} + +// This should never happen +if (count($entries) + count($events) == 0) { echo '<p><em>'.__('No entries have been posted to this thread.').'</em></p>'; -}?> +} diff --git a/include/staff/templates/thread-entry.tmpl.php b/include/staff/templates/thread-entry.tmpl.php new file mode 100644 index 0000000000000000000000000000000000000000..f06b376ef463876cfcbcc5df7737acde11d34cd9 --- /dev/null +++ b/include/staff/templates/thread-entry.tmpl.php @@ -0,0 +1,91 @@ +<?php +$entryTypes = array('M'=>'message', 'R'=>'response', 'N'=>'note'); +$user = $entry->getUser() ?: $entry->getStaff(); +$name = $user ? $user->getName() : $entry->poster; +$avatar = ''; +if ($user && ($url = $user->get_gravatar(48))) + $avatar = "<img class=\"avatar\" src=\"{$url}\"> "; +?> + +<div class="thread-entry <?php echo $entryTypes[$entry->type]; ?> <?php if ($avatar) echo 'avatar'; ?>"> +<?php if ($avatar) { ?> + <span class="<?php echo ($entry->type == 'M') ? 'pull-right' : 'pull-left'; ?> avatar"> +<?php echo $avatar; ?> + </span> +<?php } ?> + <div class="header"> + <div class="pull-right"> +<?php if ($entry->hasActions()) { + $actions = $entry->getActions(); ?> + <span class="muted-button pull-right" data-dropdown="#entry-action-more-<?php echo $entry->getId(); ?>"> + <i class="icon-caret-down"></i> + </span> + <div id="entry-action-more-<?php echo $entry->getId(); ?>" class="action-dropdown anchor-right"> + <ul class="title"> +<?php foreach ($actions as $group => $list) { + foreach ($list as $id => $action) { ?> + <li> + <a class="no-pjax" href="#" onclick="javascript: + <?php echo str_replace('"', '\\"', $action->getJsStub()); ?>; return false;"> + <i class="<?php echo $action->getIcon(); ?>"></i> <?php + echo $action->getName(); + ?></a></li> +<?php } + } ?> + </ul> + </div> +<?php } ?> + <span style="vertical-align:middle;" class="textra"> + <?php if ($entry->flags & ThreadEntry::FLAG_EDITED) { ?> + <span class="label label-bare" title="<?php + echo sprintf(__('Edited on %s by %s'), Format::datetime($entry->updated), 'You'); + ?>"><?php echo __('Edited'); ?></span> + <?php } ?> + </span> + </div> +<?php + echo sprintf(__('<b>%s</b> posted %s'), $name, + sprintf('<time class="relative" datetime="%s" title="%s">%s</time>', + date(DateTime::W3C, Misc::db2gmtime($entry->created)), + Format::daydatetime($entry->created), + Format::relativeTime(Misc::db2gmtime($entry->created)) + ) + ); ?> + <span style="max-width:500px" class="faded title truncate"><?php + echo $entry->title; ?></span> + </span> + </div> + <div class="thread-body" id="thread-id-<?php echo $entry->getId(); ?>"> + <div><?php echo $entry->getBody()->toHtml(); ?></div> +<?php + if ($entry->has_attachments) { ?> + <div class="attachments"><?php + foreach ($entry->attachments as $A) { + if ($A->inline) + continue; + $size = ''; + if ($A->file->size) + $size = sprintf('<small class="filesize faded">%s</small>', Format::file_size($A->file->size)); +?> + <span class="attachment-info"> + <i class="icon-paperclip icon-flip-horizontal"></i> + <a class="no-pjax truncate filename" href="<?php echo $A->file->getDownloadUrl(); + ?>" download="<?php echo Format::htmlchars($A->file->name); ?>" + target="_blank"><?php echo Format::htmlchars($A->file->name); + ?></a><?php echo $size;?> + </span> +<?php } ?> + </div> +<?php } ?> + </div> +<?php + if ($urls = $entry->getAttachmentUrls()) { ?> + <script type="text/javascript"> + $('#thread-id-<?php echo $entry->getId(); ?>') + .data('urls', <?php + echo JsonDataEncoder::encode($urls); ?>) + .data('id', <?php echo $entry->getId(); ?>); + </script> +<?php + } ?> +</div> diff --git a/include/staff/templates/thread-event.tmpl.php b/include/staff/templates/thread-event.tmpl.php new file mode 100644 index 0000000000000000000000000000000000000000..f98a1e3200776ca8727d586ee1dc47ead485540a --- /dev/null +++ b/include/staff/templates/thread-event.tmpl.php @@ -0,0 +1,8 @@ +<div class="thread-event <?php if ($event->uid) echo 'action'; ?>"> + <span class="type-icon"> + <i class="faded icon-<?php echo $event->getIcon(); ?>"></i> + </span> + <span class="faded description"> + <?php echo $event->getDescription(ThreadEvent::MODE_STAFF); ?> + </span> +</div> diff --git a/include/staff/ticket-view.inc.php b/include/staff/ticket-view.inc.php index 74407ea1952c951da73f9345baa0dc6fe88ddb5a..baf81ca4927c0f88ac121a4ca1763601b2f4c0f5 100644 --- a/include/staff/ticket-view.inc.php +++ b/include/staff/ticket-view.inc.php @@ -422,7 +422,7 @@ foreach (DynamicFormEntry::forTicket($ticket->getId()) as $form) { <?php $tcount = $ticket->getThreadEntries($types)->count(); ?> -<ul class="tabs threads" id="ticket_tabs" > +<ul class="tabs clean threads" id="ticket_tabs" > <li class="active"><a href="#ticket_thread"><?php echo sprintf(__('Ticket Thread (%d)'), $tcount); ?></a></li> <li><a id="ticket_tasks" href="#tasks" data-url="<?php @@ -437,6 +437,7 @@ $tcount = $ticket->getThreadEntries($types)->count(); <?php $ticket->getThread()->render(array('M', 'R', 'N')); ?> + </div> <div class="clear" style="padding-bottom:10px;"></div> <?php if($errors['err']) { ?> <div id="msg_error"><?php echo $errors['err']; ?></div> diff --git a/include/upgrader/streams/core/9143a511-00000000.patch.sql b/include/upgrader/streams/core/9143a511-00000000.patch.sql new file mode 100644 index 0000000000000000000000000000000000000000..10a6a23214346ab8fdc42578ae01fcdf867ef5a8 --- /dev/null +++ b/include/upgrader/streams/core/9143a511-00000000.patch.sql @@ -0,0 +1,28 @@ + +ALTER TABLE `%TABLE_PREFIX%ticket_event` + ADD `id` int(10) unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY FIRST, + CHANGE `ticket_id` `thread_id` int(11) unsigned NOT NULL default '0', + CHANGE `staff` `username` varchar(128) NOT NULL default 'SYSTEM', + CHANGE `state` `state` enum('created','closed','reopened','assigned','transferred','overdue','edited','viewed','error','collab','resent') NOT NULL, + ADD `data` varchar(1024) DEFAULT NULL COMMENT 'Encoded differences' AFTER `state`, + ADD `uid` int(11) unsigned DEFAULT NULL AFTER `username`, + ADD `uid_type` char(1) NOT NULL DEFAULT 'S' AFTER `uid`, + RENAME TO `%TABLE_PREFIX%thread_event`; + +-- Change the `ticket_id` column to the values in `%thread`.`id` +CREATE TABLE `%TABLE_PREFIX%_ticket_thread_evt` + (PRIMARY KEY (`object_id`)) + SELECT `object_id`, `id` FROM `%TABLE_PREFIX%thread` + WHERE `object_type` = 'T'; + +UPDATE `%TABLE_PREFIX%thread_event` A1 + JOIN `%TABLE_PREFIX%_ticket_thread_evt` A2 ON (A1.`thread_id` = A2.`object_id`) + SET A1.`thread_id` = A2.`id`; + +DROP TABLE `%TABLE_PREFIX%_ticket_thread_evt`; + +-- Attempt to connect the `username` to the staff_id +UPDATE `%TABLE_PREFIX%thread_event` A1 + LEFT JOIN `%TABLE_PREFIX%staff` A2 ON (A2.`username` = A1.`username`) + SET A1.`uid` = A2.`staff_id` + WHERE A1.`username` != 'SYSTEM'; diff --git a/scp/css/scp.css b/scp/css/scp.css index 4dd7cef3afd1c4cf0b48512b451375e6146e447f..6b2d2a33cff3c73f1c6014ed3ae13fdbb6c60634 100644 --- a/scp/css/scp.css +++ b/scp/css/scp.css @@ -55,10 +55,21 @@ div#header a { color: #666; color: rgba(0,0,0,0.5); } +.faded b { + color: #333; + color: rgba(0,0,0,0.75); +} +.faded strong { + color: #444; + color: rgba(0,0,0,0.6); +} .faded-more { color: #aaa; color: rgba(0,0,0,0.4); } +time[title]:hover { + text-decoration: underline; +} .small[class^="icon-"], .small[class*=" icon-"] { @@ -452,7 +463,7 @@ a.Icon:hover { background:#fff; } -a:not(.re-icon) { +a { color:#184E81; } @@ -821,9 +832,6 @@ h2 .reload { border:1px solid #f90; } - - - #ticket_actions { padding:5px; background:#eee; @@ -831,66 +839,151 @@ h2 .reload { border-bottom:none; margin:0; } - -#toggle_ticket_thread { - background:url(../images/icons/open.gif) 10px 50% no-repeat; +.thread-entry { + margin-bottom: 15px; + z-index: 0; } - -#toggle_notes { - background:url(../images/icons/note.gif) 10px 50% no-repeat; +.thread-entry::after { + content: ""; + border-bottom: 2px solid white; + display: block; } - -table.thread-entry { - margin-top:10px; - border:1px solid #aaa; - border-bottom:2px solid #aaa; +.thread-entry::before { + content: ""; + display: block; + border-top: 2px solid white; } - -#ticket_notes table { - margin-top:10px; - border:1px solid #ddd; - border-bottom:2px solid #ddd; +.thread-entry.avatar { + margin-left: 60px; } - -table.thread-entry th, #ticket_notes table th { - text-align:left; - border-bottom:1px solid #aaa; - font-size:10pt; - padding:5px; +.thread-entry.message.avatar { + margin-right: 60px; + margin-left: 0; } - -#ticket_notes table th { - text-align:left; - border-bottom:1px solid #ddd; - font-size:10pt; - padding:5px; - background:#F4FAFF; +.thread-entry > .avatar { + margin-left: -60px; + display:inline-block; + width:48px; + height:auto; + border-radius: 5px; } - -#ticket_notes table th em { - font-weight:normal; - font-size:10pt; - color:#666; +.thread-entry.message > .avatar { + margin-left: initial; + margin-right: -60px; } - -#ticket_notes .date { - font-weight:normal; - font-size:10pt; - color:#888; - text-align:right; +img.avatar { + border-radius: inherit; +} +.thread-entry .header { + padding: 8px 0.9em; + border: 1px solid #ccc; + border-color: rgba(0,0,0,0.2); + border-radius: 5px 5px 0 0; +} +.thread-entry.avatar .header:before { + position: absolute; + top: 7px; + right: -8px; + content: ''; + border-top: 8px solid transparent; + border-bottom: 8px solid transparent; + border-left: 8px solid #9cadcc; + display: inline-block; +} +.thread-entry.avatar .header:after { + position: absolute; + top: 7px; + right: -8px; + content: ''; + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + display: inline-block; + margin-top: 1px; +} + +.thread-entry.avatar .header { + position: relative; } -.thread-entry.message th { +.thread-entry.message .header { background:#C3D9FF; } +.thread-entry.avatar.message .header:after { + border-left: 7px solid #C3D9FF; + margin-right: 1px; +} -.thread-entry.response th { +.thread-entry.response .header { background:#FFE0B3; } +.thread-entry.avatar.response .header:before, +.thread-entry.avatar.note .header:before { + top: 7px; + left: -8px; + right: initial; + border-left: none; + border-right: 8px solid #CCC; +} +.thread-entry.note:not(.avatar) .header { + background-color: #f4f4f4; +} +.thread-entry.avatar.response .header:before { + border-right-color: #ccb3af; +} +.thread-entry.avatar.note .header:before { + border-right-color: #ccccb0; +} +.thread-entry.avatar.response .header:after, +.thread-entry.avatar.note .header:after { + top: 7px; + left: -8px; + right: initial; + border-left: none; + border-right: 7px solid #FFE0B3; + margin-left: 1px; +} -.thread-entry.note th { +.thread-entry.note .header { background:#FFE; } +.thread-entry.avatar.note .header:after { + border-right-color: #FFE; +} +.thread-entry .header .title { + max-width: 500px; + vertical-align: bottom; + display: inline-block; + margin-left: 15px; +} + +.thread-entry .thread-body { + border: 1px solid #ddd; + border-top: none; + border-bottom:2px solid #aaa; + border-radius: 0 0 5px 5px; +} +.thread-body .attachments { + background-color: #f4faff; + margin: 0 -0.9em; + position: relative; + top: 0.9em; + padding: 0.3em 0.9em; + border-top: 1px dotted #ccc; + border-top-color: rgba(0,0,0,0.2); + border-radius: 0 0 6px 6px; +} +.thread-body .attachments .filesize { + margin-left: 0.5em; +} +.thread-body .attachment-info { + margin-right: 10px; + display: inline-block; + width: 48%; +} +.thread-body .attachment-info .filename { + max-width: 80%; + max-width: calc(100% - 70px); +} #ticket_notes table td { padding:5px; @@ -912,7 +1005,7 @@ table.thread-entry th, #ticket_notes table th { } #response_options { - margin-top:30px; + margin-top:10px; } #response_options > form { @@ -1403,7 +1496,7 @@ h2 > i.help-tip { background-color:#e9f5ff; } -time { +time.faq { display:inline-block; float:right; color:#777; @@ -1825,6 +1918,23 @@ div.selected-signature .inner { top: 4px; right: 5px; } +.muted-button:hover { + border: 1px solid #aaa; + border: 1px solid rgba(0,0,0,0.3); + cursor: pointer; + background: rgba(255,255,255,0.1); + color: black; +} +.muted-button { + border-radius: 5px; + padding: 1px 5px; + margin: -1px 0 -1px 5px; + border: 1px solid rgba(0,0,0,0.15); + color: #666; + color: rgba(0,0,0,0.5); + background-color: rgba(0,0,0,0.1); + background: linear-gradient(0, rgba(0,0,0,0.1), rgba(255,255,255,0.1)); +} .sortable-rows tr td:hover { cursor: move; @@ -2003,7 +2113,7 @@ tr.disabled th { .tab_content { position: relative; - padding: 5px 0; + margin: 5px 0; } .left-tabs { margin-left: 48px; @@ -2127,6 +2237,7 @@ button a:hover { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + vertical-align: bottom; } td.indented { padding-left: 20px; @@ -2217,3 +2328,68 @@ td.indented { .sticky.bar .content { margin: auto; } + +#ticket_thread::before { + border-left: 2px dotted #ddd; + border-bottom-color: rgba(0,0,0,0.1); + position: absolute; + margin-left: 74px; + z-index: -1; + content: ""; + top: 0; + bottom: 0; + right: 0; + left: 0; +} +#ticket_thread { + z-index: 0; + position: relative; + border-bottom: 2px solid #ddd; + border-bottom-color: rgba(0,0,0,0.1); +} +.thread-event { + padding: 0 2px 15px; + margin-left: 60px; +} +.type-icon { + border-radius: 8px; + background-color: #f4f4f4; + padding: 4px 6px; + margin-right: 5px; + text-align: center; + display: inline-block; + font-size: 1.1em; + border: 1px solid #eee; + vertical-align: top; + position: relative; +} +.thread-event .type-icon::after { + content: ""; + border: 16px solid white; + position: absolute; + top: -3px; + bottom: 0; + left: -3px; + right: 0; + z-index: -1; +} +.type-icon.dark { + border-color: #666; + background-color: #949494; +} +.thread-event img.avatar { + vertical-align: middle; + border-radius: 3px; + width: auto; + max-height: 24px; + margin: -3px 3px 0; +} +.thread-event .description { + margin-left: -30px; + padding-top: 6px; + padding-left: 30px; + display: inline-block; + width: 772px; + width: calc(100% - 95px); + line-height: 1.4em; +} diff --git a/scp/js/scp.js b/scp/js/scp.js index d1c73fc8bf47f33a938b5194803683a234a90534..52ca3e4a4f5196e09c6ff6f77081e0a6cfa7b242 100644 --- a/scp/js/scp.js +++ b/scp/js/scp.js @@ -578,6 +578,15 @@ $(document).on('focus', 'form.spellcheck textarea, form.spellcheck input[type=te $(this).attr({'spellcheck':'true', 'lang': lang}); }); +$(document).on('click', '.thread-entry-group a', function() { + var inner = $(this).parent().find('.thread-entry-group-inner'); + if (inner.is(':visible')) + inner.slideUp(); + else + inner.slideDown(); + return false; +}); + $.toggleOverlay = function (show) { if (typeof(show) === 'undefined') { return $.toggleOverlay(!$('#overlay').is(':visible')); diff --git a/scp/tickets.php b/scp/tickets.php index 4f0dcec4e3642c8628ed5bc0f743926d746becc2..e78104f0c5f294b8ef3eef02651c034b9918b1df 100644 --- a/scp/tickets.php +++ b/scp/tickets.php @@ -186,14 +186,6 @@ if($_POST && !$errors): $errors['assignId']=__('Ticket already assigned to the team.'); } - //Comments are not required on self-assignment (claim) - if($claim && !$_POST['assign_comments']) - $_POST['assign_comments'] = sprintf(__('Ticket claimed by %s'),$thisstaff->getName()); - elseif(!$_POST['assign_comments']) - $errors['assign_comments'] = __('Assignment comments required'); - elseif(strlen($_POST['assign_comments'])<5) - $errors['assign_comments'] = __('Comment too short'); - if(!$errors && $ticket->assign($_POST['assignId'], $_POST['assign_comments'], !$claim)) { if($claim) { $msg = __('Ticket is NOW assigned to you!'); diff --git a/setup/inc/streams/core/install-mysql.sql b/setup/inc/streams/core/install-mysql.sql index 26f129681118b2c67eea32dc5f4b74f78d30a656..3ecb6bff63ea7a9df79958e498b18981fdde17f1 100644 --- a/setup/inc/streams/core/install-mysql.sql +++ b/setup/inc/streams/core/install-mysql.sql @@ -712,18 +712,22 @@ CREATE TABLE `%TABLE_PREFIX%lock` ( KEY `staff_id` (`staff_id`) ) DEFAULT CHARSET=utf8; -DROP TABLE IF EXISTS `%TABLE_PREFIX%ticket_event`; -CREATE TABLE `%TABLE_PREFIX%ticket_event` ( - `ticket_id` int(11) unsigned NOT NULL default '0', +DROP TABLE IF EXISTS `%TABLE_PREFIX%thread_event`; +CREATE TABLE `%TABLE_PREFIX%thread_event` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `thread_id` int(11) unsigned NOT NULL default '0', `staff_id` int(11) unsigned NOT NULL, `team_id` int(11) unsigned NOT NULL, `dept_id` int(11) unsigned NOT NULL, `topic_id` int(11) unsigned NOT NULL, - `state` enum('created','closed','reopened','assigned','transferred','overdue') NOT NULL, - `staff` varchar(255) NOT NULL default 'SYSTEM', + `state` enum('created','closed','reopened','assigned','transferred','overdue','edited','viewed','error','collab','resent') NOT NULL, + `data` varchar(1024) DEFAULT NULL COMMENT 'Encoded differences', + `username` varchar(128) NOT NULL default 'SYSTEM', + `uid` int(11) unsigned DEFAULT NULL, + `uid_type` char(1) NOT NULL DEFAULT 'S', `annulled` tinyint(1) unsigned NOT NULL default '0', `timestamp` datetime NOT NULL, - KEY `ticket_state` (`ticket_id`, `state`, `timestamp`), + KEY `ticket_state` (`thread_id`, `state`, `timestamp`), KEY `ticket_stats` (`timestamp`, `state`) ) DEFAULT CHARSET=utf8; diff --git a/tickets.php b/tickets.php index 0c6dd8947c1925f025552e20971b8f8cc0079c82..875fdfec40dc2d4770e4891c8a5cd76832c3a5d7 100644 --- a/tickets.php +++ b/tickets.php @@ -49,6 +49,7 @@ if ($_POST && is_object($ticket) && $ticket->getId()) { $errors['err']=__('Access Denied. Possibly invalid ticket ID'); else { $forms=DynamicFormEntry::forTicket($ticket->getId()); + $changes = array(); foreach ($forms as $form) { $form->setSource($_POST); if (!$form->isValid()) @@ -56,11 +57,13 @@ if ($_POST && is_object($ticket) && $ticket->getId()) { } } if (!$errors) { - foreach ($forms as $f) $f->save(); + foreach ($forms as $f) { + $changes += $f->getChanges(); + $f->save(); + } + if ($changes) + $ticket->logEvent('edited', array('fields' => $changes)); $_REQUEST['a'] = null; //Clear edit action - going back to view. - $ticket->logNote(__('Ticket details updated'), sprintf( - __('Ticket details were updated by client %s <%s>'), - $thisclient->getName(), $thisclient->getEmail())); } break; case 'reply':