Time tracking you can host anywhere, built on Django. Full export support in multiple formats and easily extensible.
djangodockerhandcodedpythonself-hostedtime-trackingtimetracker
1<template>
2<div class="container-fluid">
3 <div class="row row-background">
4 <div class="col-md-7">
5 <h1 class="row-title">
6 <icon :icon="['fas', 'book']" class="fa-sm mr-2"/>
7 Reports
8 </h1>
9 </div>
10 <div class="col-md-5 d-flex align-items-center justify-content-end">
11 <template v-if="is_staff">
12 <select
13 v-model="exportFormat"
14 class="export-select custom-select form-control-sm w-25 flex-fill mr-2">
15 <option value="csv">csv</option>
16 <option value="xls">xls</option>
17 <option value="tsv">tsv</option>
18 <option value="ods">ods</option>
19 <option value="json">json</option>
20 <option value="yaml">yaml</option>
21 <option value="html">html</option>
22 </select>
23 <button
24 id="export-report"
25 class="btn btn-light flex-fill text-nowrap mr-2"
26 @click="exportReport">
27 <icon :icon="['fas', 'download']" class="mr-1"/>
28 Export
29 </button>
30 </template>
31
32 <popover class="flex-fill mr-2">
33 <template slot="popover-button">
34 <span v-bind:class="{active: isFiltered}">
35 <icon :icon="['fas', 'filter']" class="mr-1"/>
36 Filter
37 </span>
38 </template>
39 <template slot="popover-content">
40 <small class="text-muted mb-2 d-block">Filter by column, multiple filters are okay</small>
41 <div class="row">
42 <div class="col-sm-6">
43 <div class="form-group">
44 <label>User</label>
45 <selector
46 id="report-filter-user"
47 v-model="user"
48 :options="users"
49 :selected="user"
50 placeholder="User"
51 allowclear/>
52 </div>
53 <div class="form-group">
54 <label>Client</label>
55 <selector
56 id="report-filter-client"
57 v-model="client"
58 :options="clients"
59 :selected="client"
60 placeholder="Client"
61 allowclear/>
62 </div>
63 <div class="form-group mb-0">
64 <label>Project</label>
65 <selector
66 id="report-filter-project"
67 v-model="project"
68 :options="projects"
69 :selected="project"
70 placeholder="Project"
71 allowclear/>
72 </div>
73 </div>
74 <div class="col-sm-6">
75 <div class="form-group">
76 <label>Task</label>
77 <selector
78 id="report-filter-task"
79 v-model="task"
80 :options="tasks"
81 :selected="task"
82 placeholder="Task"
83 allowclear/>
84 </div>
85 <div class="form-group">
86 <label>Min. Date</label>
87 <datepicker
88 id="report-filter-min-date"
89 v-model="minDate"
90 type="text"
91 class="form-control date-input"
92 placeholder="Min. date"
93 allowclear/>
94 </div>
95 <div class="form-group mb-0">
96 <label>Max. Date</label>
97 <datepicker
98 id="report-filter-max-date"
99 v-model="maxDate"
100 type="text"
101 class="form-control date-input"
102 placeholder="Max. date"
103 allowclear/>
104 </div>
105 </div>
106
107 <div class="clear col-sm-12">
108 <button
109 class="btn btn-info w-100 clear"
110 @click.prevent.exact="clear">
111 Clear
112 </button>
113 </div>
114
115 </div>
116 </template>
117 </popover>
118
119 <popover class="flex-fill">
120 <template slot="popover-button">
121 <icon :icon="['fas', 'sort']" class="mr-1"/>
122 Sort
123 </template>
124 <template slot="popover-content">
125 <small class="text-muted mb-2 d-block">Sort by column and direction</small>
126 <div class="row">
127 <div class="col-md-6">
128 <selector
129 id="report-sort-by"
130 v-model="orderBy"
131 :options="orderByOptions"
132 selected="date"
133 placeholder="Order by"/>
134 </div>
135 <div class="col-md-6">
136 <selector
137 id="report-order-dir"
138 v-model="orderDir"
139 :options="orderDirOptions"
140 selected="desc"
141 placeholder="Order direction"/>
142 </div>
143 </div>
144 </template>
145 </popover>
146 </div>
147 </div>
148
149 <template v-if="entries.length">
150 <div class="row">
151 <table class="table table-striped table-responsive-md table-sm small">
152 <thead class="thead-dark">
153 <tr>
154 <th scope="col">
155 <icon :icon="['fas', 'address-book']" class="mr-1"/>
156 Client
157 </th>
158 <th scope="col">
159 <icon :icon="['fas', 'briefcase']" class="mr-1"/>
160 Project
161 </th>
162 <th scope="col">
163 <icon :icon="['fas', 'tasks']" class="mr-1"/>
164 Task
165 </th>
166 <th scope="col">
167 <icon :icon="['fas', 'user-circle']" class="mr-1"/>
168 User
169 </th>
170 <th scope="col">
171 <icon :icon="['fas', 'calendar']" class="mr-1"/>
172 Date
173 </th>
174 <th scope="col">
175 <icon :icon="['fas', 'clock']" class="mr-1"/>
176 Duration
177 </th>
178 <th scope="col">
179 <icon :icon="['fas', 'comment']" class="mr-1"/>
180 Note
181 </th>
182 </tr>
183 </thead>
184 <tbody>
185 <tr
186 v-for="entry in entries"
187 :key="entry.id"
188 :id="'entry-' + entry.id">
189 <td>{{ entry.project ? getClient(getProject(entry.project).client).name : '' }}</td>
190 <td>{{ entry.project ? getProject(entry.project).name : '' }}</td>
191 <td>{{ entry.task ? getTask(entry.task).name : '' }}</td>
192 <td>{{ entry.user ? getUser(entry.user).username : '' }}</td>
193 <td>{{ $moment(entry.date).format('LL') }}</td>
194 <td class="text-right">{{ $moment.duration(entry.duration, 'hours').format('h[h] mm[m]') }}</td>
195 <td class="entry__note">{{ entry.note }}</td>
196 </tr>
197 </tbody>
198 <tfoot>
199 <tr>
200 <td>
201 <small class="text-muted text-uppercase">Count</small>
202 {{ entries.length }}
203 </td>
204 <td></td>
205 <td></td>
206 <td></td>
207 <td></td>
208 <td class="text-right text-nowrap">
209 <small class="text-muted text-uppercase">Sum</small>
210 {{ $moment.duration(total, 'hours').format('d[d] h[h] m[m]') }}
211 </td>
212 <td></td>
213 </tr>
214 </tfoot>
215 </table>
216 </div>
217 </template>
218 <div v-else class="container">
219 <div class="row">
220 <div class="col text-center py-4">
221 <div class="spinner-border" role="status">
222 <span class="sr-only">Loading...</span>
223 </div>
224 </div>
225 </div>
226 </div>
227</div>
228</template>
229
230
231<script>
232import {mapGetters, mapState, mapActions, mapMutations} from 'vuex';
233import $ from 'jquery';
234
235import Datepicker from '../datepicker.vue';
236import Popover from '../popover.vue';
237import Entry from '../entry.vue';
238import Selector from '../selector.vue';
239
240
241export default {
242 components: {
243 Datepicker,
244 Popover,
245 Entry,
246 Selector,
247 },
248 data() {
249 return {
250 is_staff: timestrapConfig.USER.IS_STAFF,
251 editable: false,
252 next: null,
253 previous: null,
254
255 exportFormat: 'csv',
256
257 orderByOptions: [
258 {id: 'project__client__name', text: 'Client'},
259 {id: 'date', text: 'Date'},
260 {id: 'project__name', text: 'Project'},
261 {id: 'task__name', text: 'Task'},
262 {id: 'user__username', text: 'User'},
263 ],
264 orderDirOptions: [
265 {id: 'asc', text: 'Ascending'},
266 {id: 'desc', text: 'Descending'},
267 ],
268
269 orderBy: 'date',
270 orderDir: 'desc',
271
272 minDate: null,
273 maxDate: null,
274 user: null,
275 client: this.$store.state.reports.client,
276 project: this.$store.state.reports.project,
277 task: this.$store.state.reports.task,
278 };
279 },
280 watch: {
281 orderBy() {
282 this.setOrderBy(this.orderBy);
283 this.$store.dispatch('reports/getReport');
284 },
285 orderDir() {
286 this.setOrderDir(this.orderDir);
287 this.$store.dispatch('reports/getReport');
288 },
289 user() {
290 this.setUser(this.user);
291 this.$store.dispatch('reports/getReport');
292 },
293 client() {
294 this.setClient(this.client);
295 this.$store.dispatch('reports/getReport');
296 },
297 project() {
298 this.setProject(this.project);
299 this.$store.dispatch('reports/getReport');
300 },
301 task() {
302 this.setTask(this.task);
303 this.$store.dispatch('reports/getReport');
304 },
305 minDate() {
306 this.setMinDate(this.minDate);
307 this.$store.dispatch('reports/getReport');
308 },
309 maxDate() {
310 this.setMaxDate(this.maxDate);
311 this.$store.dispatch('reports/getReport');
312 },
313 },
314 computed: {
315 ...mapState({
316 entries: state => state.reports.all,
317 total: state => state.reports.total,
318 subtotal: state => state.reports.subtotal,
319 }),
320 ...mapGetters({
321 tasks: 'tasks/getSelectTasks',
322 projects: 'projects/getSelectProjects',
323 clients: 'clients/getSelectClients',
324 users: 'users/getSelectUsers',
325 getTask: 'tasks/getTask',
326 getProject: 'projects/getProject',
327 getClient: 'clients/getClient',
328 getUser: 'users/getUser',
329 isFiltered: 'reports/isFiltered'
330 }),
331 },
332 mounted() {
333 this.$store.dispatch('reports/getReport');
334 },
335 methods: {
336 ...mapMutations('reports', [
337 'setOrderBy',
338 'setOrderDir',
339
340 'setUser',
341 'setClient',
342 'setProject',
343 'setTask',
344 'setMinDate',
345 'setMaxDate',
346 ]),
347 clear(){
348 this.minDate = null;
349 this.maxDate = null;
350 this.user = null;
351 this.client = null;
352 this.project = null;
353 this.task = null;
354 },
355 exportReport() {
356 let ordering = (this.orderDir === 'desc' ? '-' : '') + this.orderBy;
357 if (this.orderBy !== 'date') {
358 ordering += ',-date';
359 }
360
361 function urlToId(url) {
362 if (url !== null) {
363 let splitUrl = url.split('/');
364 return splitUrl[splitUrl.length - 2];
365 } else {
366 return "";
367 }
368 }
369
370 const query = {
371 ordering: ordering,
372 user: urlToId(this.user),
373 project: urlToId(this.project),
374 project__client: urlToId(this.client),
375 task: urlToId(this.task),
376 min_date: this.minDate,
377 max_date: this.maxDate,
378 export_format: this.exportFormat,
379 };
380 document.location.href = timestrapConfig.CORE_URLS.REPORTS_EXPORT + '?' + $.param(query);
381 },
382 },
383};
384</script>
385
386
387<style lang="scss" scoped>
388.row-background {
389 background-image: url('/static/imgs/background.jpg');
390 background-size: cover;
391 background-position: center center;
392 padding-top: 3rem;
393 padding-bottom: 3rem;
394}
395
396.row-title {
397 margin-bottom: 0;
398 color: #fff;
399 text-shadow: 1px 1px 0 #000;
400 font-weight: bold;
401
402 .fa-sm {
403 filter: drop-shadow(1px 1px 0 #000);
404 }
405}
406
407.entry__note {
408 max-width: 20vw;
409 white-space: nowrap;
410 overflow: hidden;
411 text-overflow: ellipsis;
412}
413
414.clear{
415 margin-top: .5rem;
416}
417
418.active{
419 /*svg{*/
420 /* color: green;*/
421 /*}*/
422 font-weight: bold;
423}
424
425</style>