This commit is contained in:
Maxime Prades 2014-04-10 18:09:27 -07:00
Коммит 30367fca13
11 изменённых файлов: 684 добавлений и 0 удалений

48
LICENSE Normal file
Просмотреть файл

@ -0,0 +1,48 @@
Copyright 2012 Zendesk
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
ADDITIONAL TERMS AND CONDITIONS
Capitalized terms used herein have the meaning set forth in the
Zendesk, Inc. (“Zendesk”) Terms of Service (available at www.zendesk.com/company/terms)
(the “Terms”). The software made available herein constitutes the source code for a
Zendesk Application (“Application Software”) which may be implemented to
enable features or functionality to be utilized in connection with a subscription
to the Service. Notwithstanding anything to the contrary set forth in the Terms
or any other agreement by and between you and Zendesk, Application Software is
provided “AS IS” and on an “AS AVAILABLE” basis, and that Zendesk makes no warranty
as to the Application Software. ZENDESK DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE IMPLIED WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT AND THOSE ARISING FROM A COURSE
OF DEALING OR USAGE OF TRADE RELATED TO THE APPLICATION SOFTWARE, ITS USE OR ANY INABILITY
TO USE IT OR THE RESULTS OF ITS USE.
Zendesk makes Zendesk Applications available for use through the user interface of
the Service in the manner described (for each Zendesk Application) at www.zendesk.com/apps
(“Service Implementation”). Use of Application Software, other than in connection with a
Service Implementation is subject to the following risks and conditions: (i) the Application
Software may contain errors, design flaws or other problems; and (ii) use of the Application
Software may result in unexpected results, loss of data, project delays or other unpredictable
damage or loss. Zendesk only supports the Application Software currently available and installed
through the Service Implementation. By utilizing Application Software other than in connection
with the Service Implementation, you are agreeing that Zendesk shall have no obligation to
correct any bugs, defects or errors in the Application Software you have utilized or
otherwise to support or maintain the Application Software you have utilized.
If you elect to utilize any Application Software other than through a Service Implementation,
you are agreeing to release Zendesk from any claim with regard to the Application Software,
its operation, availability or its failure to operate or be available.
Without limiting the generality of the foregoing, You acknowledge and agree that neither the use,
availability nor operation of any Application Software shall be subject to any service level
commitment applicable to the Service.

46
README.md Normal file
Просмотреть файл

@ -0,0 +1,46 @@
:warning: *Use of this software is subject to important terms and conditions as set forth in the License file* :warning:
# Time Tracking App
## Description:
Helps you track time on your tickets. You'll be able to submit custom time and restrict time submission.
## App location:
* Ticket sidebar
* New Ticket sidebar
## Features:
* Track current spent time on a ticket.
* Track total spent time on a ticket.
* Ability to select in which unit to report the total time spent (milliseconds, seconds, minutes).
* Log every time submission (time submitted, agent name, status submitted, submission date). *Can be turned off*.
* Download time logs as a csv file.
* Ability to auto-pause/resume the timer when the agent isn't focused on the ticket. *Can be turned off*.
* Ability for the agent to manually pause/resume the timer. *Can be turned off*.
* Ability for the agent to restart the timer. *Can be turned off*.
* Ability for the agent to submit his own spent time. *Can be turned off*.
## Set-up/installation instructions:
You will need to create 2 ticket fields:
* A Numeric ticket field that will contain the total time spent (used to report on GoodData).
* A Multi-line text ticket field that will store the app configuration and time logs.
After installation, on the app settings page:
* Put the previously create Numeric ticket field ID in the "Time Field ID" setting.
* Put the previously create Multi-line ticket field ID in the "Config Field ID" setting.
* Enable/Disable settings to customise as you need it.
## Contribution:
Pull requests are welcome.
## Screenshot(s):
Default view of the app:
![](http://i.imgur.com/V1x1coZ.png)

31
app.css Normal file
Просмотреть файл

@ -0,0 +1,31 @@
.current-timer {
font-weight: bold;
}
.live-timer {
font-size: 15px;
}
.text-center {
text-align: center;
}
input {
text-align: inherit;
font-size: 20px;
padding: 10px;
margin: 10px;
}
.timelogs-container {
display: none;
}
table {
margin-bottom: none;
}
.btn-group {
display: inline-block;
padding-top: 7px;
}

342
app.js Normal file
Просмотреть файл

@ -0,0 +1,342 @@
(function() {
'use_strict';
return {
events: {
'app.activated' : 'onAppActivated',
'app.deactivated' : 'onAppFocusOut',
'app.willDestroy' : 'onAppWillDestroy',
'ticket.save' : 'onTicketSave',
'ticket.form.id.changed' : 'onTicketFormChanged',
'click .pause' : 'onPauseClicked',
'click .resume' : 'onResumeClicked',
'click .reset' : 'onResetClicked',
'click .modal-save' : 'onModalSaveClicked',
'click .timelogs-opener' : 'onTimeLogsContainerClicked',
'shown .modal' : 'onModalShown',
'hidden .modal' : 'onModalHidden'
},
/*
*
* EVENT CALLBACKS
*
*/
onAppActivated: function(app) {
if (app.firstLoad) {
_.defer(this.initialize.bind(this));
} else {
this.onAppFocusIn();
}
},
onAppWillDestroy: function() {
clearInterval(this.timeLoopID);
},
onAppFocusOut: function() {
if (this.setting('auto_pause_resume')) {
this.autoPause();
}
},
onAppFocusIn: function() {
if (this.setting('auto_pause_resume') &&
!this.manuallyPaused) {
this.autoResume();
}
},
onTicketFormChanged: function() {
_.defer(this.hideFields.bind(this));
},
onTicketSave: function() {
if (this.setting('time_submission')) {
return this.promise(function(done, fail) {
this.saveHookPromiseDone = done;
this.saveHookPromiseFail = fail;
this.renderTimeModal();
}.bind(this));
} else {
this.updateTime(this.elapsedTime);
return true;
}
},
onPauseClicked: function(e) {
var $el = this.$(e.currentTarget);
$el.removeClass('pause').addClass('resume');
$el.find('i').prop('class', 'icon-play');
this.manuallyPaused = this.paused = true;
},
onResumeClicked: function(e) {
var $el = this.$(e.currentTarget);
$el.removeClass('resume').addClass('pause');
$el.find('i').prop('class', 'icon-pause');
this.manuallyPaused = this.paused = false;
},
onResetClicked: function() {
this.elapsedTime = 0;
},
onTimeLogsContainerClicked: function(e) {
var $el = this.$(e.currentTarget);
if (!this.$('.timelogs-container').is(':visible')) {
$el.addClass('active');
this.$('.timelogs-container').show();
} else {
$el.removeClass('active');
this.$('.timelogs-container').hide();
}
},
onModalSaveClicked: function() {
var timeString = this.$('.modal-time').val();
try {
this.updateTime(this.TimeHelper.timeStringToMs(timeString));
this.saveHookPromiseIsDone = true; // Flag that saveHookPromiseDone is gonna be called after hiding the modal
this.$('.modal').modal('hide');
this.saveHookPromiseDone();
} catch (e) {
if (e.message == 'bad_time_format') {
services.notify(this.I18n.t('errors.bad_time_format'), alert);
} else {
throw e;
}
}
},
onModalShown: function() {
var timeout = 15,
$timeout = this.$('span.modal-timer'),
$modal = this.$('.modal');
this.modalTimeoutID = setInterval(function() {
timeout -= 1;
$timeout.html(timeout);
if (timeout === 0) {
$modal.modal('hide');
}
}.bind(this), 1000);
},
onModalHidden: function() {
clearInterval(this.modalTimeoutID);
if (!this.saveHookPromiseIsDone) {
this.saveHookPromiseFail(this.I18n.t('errors.save_hook'));
}
},
/*
*
* METHODS
*
*/
initialize: function() {
this.hideFields();
this.time = this.getTimeObject();
this.timeLoopID = this.setTimeLoop();
var timelogs = this.TimeHelper.prettyTimeLogs(this.time.logs);
this.switchTo('main', {
manual_pause_resume: this.setting('manual_pause_resume'),
timelogs: timelogs,
display_reset: this.setting('reset'),
display_timer: this.setting('display_timer'),
display_timelogs: this.setting('display_timelogs'),
timelogs_csv_filename: helpers.fmt('ticket-timelogs-%@',
this.ticket().id()),
timelogs_csv_string: encodeURI(this.timelogsToCsvString(timelogs))
});
this.$('tr').tooltip({ placement: 'left', html: true });
},
updateMainView: function(time) {
this.$('.live-timer').html(this.TimeHelper.msToTime(time));
this.$('.live-totaltimer').html(this.TimeHelper.msToTime(
this.time.value + time
));
},
hideFields: function() {
_.each([this.timeFieldLabel(), this.timeObjectFieldLabel()], function(f) {
var field = this.ticketFields(f);
if (field) {
field.hide();
}
}, this);
},
/*
* TIME RELATED
*/
setTimeLoop: function() {
var interval = 1000;
this.elapsedTime = 0;
return setInterval(function() {
if (!this.paused) {
this.elapsedTime += interval;
this.updateMainView(this.elapsedTime);
}
}.bind(this), interval);
},
updateTime: function(time) {
this.time.logs.push({
time: time,
submitter_id: this.currentUser().id(),
submitter_name: this.currentUser().name(),
submitted_at: new Date().getTime(),
status: this.ticket().status()
});
this.time.value += time;
this.saveTimeObject();
},
saveTimeObject: function() {
this.ticket().customField(
this.timeFieldLabel(),
String(this.normalizeTimeForSave(this.time.value))
);
this.ticket().customField(
this.timeObjectFieldLabel(),
JSON.stringify(this.time)
);
},
autoResume: function() {
this.paused = false;
},
autoPause: function() {
this.paused = true;
},
renderTimeModal: function() {
this.$('.modal-time').val(this.TimeHelper.msToTime(this.elapsedTime));
this.$('.modal').modal('show');
},
/*
*
* HELPERS
*
*/
timelogsToCsvString: function(logs) {
return _.reduce(logs, function(memo, log) {
return memo + helpers.fmt('%@\n', [ log.time, log.submitter_name, log.date_submitted_at, log.status ]);
}, 'Time,Submitter,Submitted At,status\n', this);
},
// Returns a new time in unit specified by the setting (mm|ss|ms)
normalizeTimeForSave: function(time) {
var timeUnits = {
"minute": 60000,
"second": 1000,
"millisecond": 1
},
exponent = timeUnits[this.setting('time_unit')] || timeUnits.second;
return Math.floor(time / exponent);
},
getTimeObject: function() {
var timeObject = this.ticket().customField(this.timeObjectFieldLabel());
if (timeObject) {
return JSON.parse(timeObject);
} else {
return { value: 0, logs: [] };
}
},
timeObjectFieldLabel: function() {
return this.buidFieldLabel(this.setting('time_object_field_id'));
},
timeFieldLabel: function() {
return this.buidFieldLabel(this.setting('time_field_id'));
},
buidFieldLabel: function(id) {
return helpers.fmt('custom_field_%@', id);
},
TimeHelper: {
msToTime: function(millis) {
var time = parseInt(millis / 1000, 10),
seconds = time % 60;
time = parseInt(time / 60, 10);
var minutes = time % 60,
hours = parseInt(time / 60, 10) % 24;
return helpers.fmt('%@:%@:%@',
this.addInsignificantZero(hours),
this.addInsignificantZero(minutes),
this.addInsignificantZero(seconds));
},
timeStringToMs: function(str) {
var re = /^([\d]{2}):([\d]{2}):([\d]{2})$/,
result = re.exec(str);
if (!result ||
result.length != 4) {
throw { message: 'bad_time_format' };
} else {
return (parseInt(result[1], 10) * 3600000) +
(parseInt(result[2], 10) * 60000) +
(parseInt(result[3], 10) * 1000); // hours + minutes + seconds in milliseconds
}
},
addInsignificantZero: function(n) {
return ( n < 10 ? '0' : '') + n;
},
prettyTimeLogs: function(logs) {
return _.reduce(logs, function(memo, log) {
var logDecorator = _.clone(log),
submitted_at = new Date(log.submitted_at);
logDecorator.date_submitted_at = submitted_at.toLocaleString();
logDecorator.time = this.msToTime(log.time);
memo.push(logDecorator);
return memo;
}, [], this);
}
}
};
}());

Двоичные данные
assets/logo-small.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 3.6 KiB

Двоичные данные
assets/logo.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 44 KiB

Двоичные данные
assets/promotion-image.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 22 KiB

63
manifest.json Normal file
Просмотреть файл

@ -0,0 +1,63 @@
{
"name": "Time Tracking",
"author": {
"name": "Zendesk",
"email": "support@zendesk.com",
"url": "https://www.zendesk.com"
},
"defaultLocale": "en",
"private": false,
"singleInstall": true,
"location": [ "new_ticket_sidebar", "ticket_sidebar" ],
"frameworkVersion": "1.0",
"version": "0.1",
"parameters": [
{
"name": "time_field_id",
"type": "number",
"required": true
},
{
"name": "time_object_field_id",
"type": "number",
"required": true
},
{
"name": "display_timer",
"type": "checkbox",
"default": true
},
{
"name": "display_timelogs",
"type": "checkbox",
"default": true
},
{
"name": "auto_pause_resume",
"type": "checkbox",
"default": true
},
{
"name": "manual_pause_resume",
"type": "checkbox",
"default": true
},
{
"name": "reset",
"type": "checkbox",
"default": true
},
{
"name": "time_submission",
"type": "checkbox",
"default": true
},
{
"name": "time_unit",
"type": "text",
"default": "second"
}
]
}

5
templates/layout.hdbs Normal file
Просмотреть файл

@ -0,0 +1,5 @@
<header>
<span class="logo"/>
<h3>{{setting "name"}}</h3>
</header>
<section data-main/>

81
templates/main.hdbs Normal file
Просмотреть файл

@ -0,0 +1,81 @@
<div class="timers {{#unless display_timer }}hidden{{/unless}}">
<div class="row-fluid current-timer">
<div class="span8">
{{t "views.main.current_time_spent"}}:
</div>
<div class="span4">
<div class="live-timer pull-right">00:00:00</div>
</div>
</div>
<div class="row-fluid">
<div class="span8">
{{t "views.main.total_time_spent"}}:
</div>
<div class="span4">
<div class="live-totaltimer pull-right">00:00:00</div>
</div>
</div>
</div>
<div class="text-center">
<div class="btn-group">
{{#if manual_pause_resume}}
<a class="btn pause" title="{{t "views.main.pause_resume"}}"><i class="icon-pause"></i></a>
{{/if}}
{{#if display_reset}}
<a class="btn reset" title="{{t "views.main.reset"}}"><i class="icon-repeat"></i></a>
{{/if}}
{{#if display_timelogs}}
{{#if timelogs}}
<a class="btn timelogs-opener"><strong>{{t "views.main.timelogs"}}</strong></a>
{{/if}}
{{/if}}
</div>
</div>
{{#if timelogs}}
<div class="row-fluid timelogs-container">
<table class="table">
<thead>
<th>{{t "views.main.timelogs_table.agent"}}</th>
<th>{{t "views.main.timelogs_table.time"}}</th>
<th>{{t "views.main.timelogs_table.status"}}</th>
</thead>
<tbody>
{{#each timelogs}}
<tr data-toggle="tooltip" title="{{date_submitted_at}}">
<td>{{submitter_name}}</td>
<td>{{time}}</td>
<td>
<span class="ticket_status_label {{status}}">
<strong>{{status}}</strong>
</span>
</td>
</tr>
{{/each}}
</tbody>
</table>
<div class="text-center">
<a download="{{timelogs_csv_filename}}" href="data:text/csv;charset=utf-8,{{timelogs_csv_string}}" class="btn"><i class="icon-file"></i> CSV Timelogs</a>
</div>
</div>
{{/if}}
<div class="modal hide fade" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true">
<div class="modal-header">
<button type="button" class="close" aria-hidden="true" data-dismiss="modal">×</button>
<h3>{{setting "name"}}</h3>
</div>
<div class="modal-body">
<p>{{t "views.modal.body"}}</p>
<div class="modal-time-container text-center">
<input class="modal-time" type="text" value="HH:MM:SS"/>
</div>
</div>
<div class="modal-footer">
<button class="btn" aria-hidden="true" data-dismiss="modal">{{t "views.modal.close"}} (<span class="modal-timer">15</span>)</button>
<button class="btn btn-primary modal-save">{{t "views.modal.save"}}</button>
</div>
</div>

68
translations/en.json Normal file
Просмотреть файл

@ -0,0 +1,68 @@
{
"app": {
"description": "Helps you track time spent on tickets.",
"name": "Time Tracking",
"parameters": {
"time_field_id": {
"label": "Time Field ID",
"helpText": "The ID of a custom numeric field that will hold the total time."
},
"time_object_field_id": {
"label": "Config Field ID",
"helpText": "The ID of a custom multi-line field that will hold the config/timelogs."
},
"display_timer": {
"label": "Display time",
"helpText": "Agent can see the timers (current/total)"
},
"display_timelogs": {
"label": "Display Timelogs",
"helpText": "Agent can see timelogs."
},
"auto_pause_resume": {
"label": "Auto Pause/Resume",
"helpText": "The time will pause when the focus on the ticket is lost and resume itself as soon as the agent is back on the ticket."
},
"manual_pause_resume": {
"label": "Manual Pause/Resume/Reset",
"helpText": "Agent can manually pause/resume/reset the timer."
},
"reset": {
"label": "Reset current time",
"helpText": "Agent can reset his current time spent on a ticket."
},
"time_submission": {
"label": "Time submission",
"helpText": "Agent submit his own time."
},
"time_unit": {
"label": "Unit of time",
"helpText": "Choose in which unit the time will be saved. You can choose millisecond/second/minute. Defaults to second."
}
}
},
"views": {
"main": {
"pause": "Pause",
"current_time_spent": "Current Time Spent",
"total_time_spent": "Total Time Spent",
"pause_resume": "Pause/Resume",
"reset": "Reset",
"timelogs": "Timelogs",
"timelogs_table": {
"agent": "Agent",
"time": "Time",
"status": "Status"
}
},
"modal": {
"body": "The system shows that you spent the following time on this ticket, you can either edit it or leave it as is and click \"Save Ticket\".",
"close": "Close",
"save": "Save Ticket"
}
},
"errors": {
"save_hook": "You need to submit a time before saving this ticket!",
"bad_time_format": "Bad time format (HH:MM:SS)."
}
}