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="selector">
3 <div
4 class="focuser"
5 v-if="edit"
6 @click="toggleEdit()"/>
7
8 <div
9 class="select border shadow rounded w-100"
10 v-if="edit"
11 @keydown.esc.prevent="toggleEdit()"
12 @keydown.tab.prevent="toggleEdit()"
13 @keydown.up.prevent="selectedUp()"
14 @keydown.down.prevent="selectedDown()"
15 @keydown.enter.prevent="activeUpdate()">
16 <div class="form-control d-flex rounded-0 border-0 rounded-top bg-light">
17 <span class="badge badge-dark d-block-inline mr-2 px-2" v-if="selectedObject">
18 {{ selectedObject.text }}
19 <span
20 v-if="active"
21 class="ml-2 clear"
22 @click="clearActive()">
23 <icon :icon="['fas', 'times-circle']"/>
24 </span>
25 </span>
26 <input
27 type="text"
28 v-model="search"
29 ref="search"
30 class="form-control-plaintext flex-fill"
31 placeholder="Type to filter...">
32 </div>
33 <div class="options list-group list-group-flush w-100 shadow">
34 <template v-if="optionsFiltered(search).length">
35 <a
36 v-for="option in optionsFiltered(search)"
37 :key="option.id"
38 href="#"
39 :class="['option', 'list-group-item', 'list-group-item-action', active === option.id ? 'active' : '']"
40 @click.prevent="clickUpdate(option.id)">
41 {{ option.text }}
42 </a>
43 </template>
44 <template v-else>
45 <div class="list-group-item text-muted">Nothing matches your filter.</div>
46 </template>
47 </div>
48 </div>
49
50 <div
51 :class="['selected', 'form-control', 'shadow-sm', 'overflow-hidden', small ? 'form-control-sm' : '']"
52 tabindex="0"
53 ref="selected"
54 @click.prevent="toggleEdit()"
55 @keydown.up.prevent="toggleEdit()"
56 @keydown.down.prevent="toggleEdit()"
57 @keydown.enter.prevent="toggleEdit()"
58 v-if="selectedObject">
59 {{ selectedObject.text }}
60 </div>
61 </div>
62</template>
63
64
65<script>
66 import Vue from 'vue';
67
68
69 export default {
70 data() {
71 return {
72 search: '',
73 edit: false,
74 active: null,
75 }
76 },
77 props: {
78 options: {
79 type: Array,
80 default: [
81 {
82 id: '',
83 text: 'Empty',
84 }
85 ],
86 },
87 selected: {
88 type: String,
89 default: '',
90 },
91 placeholder: {
92 type: String,
93 default: 'Empty',
94 },
95 small: {
96 type: Boolean,
97 default: false,
98 }
99 },
100 computed: {
101 selectedObject(){
102 if (this.selected) {
103 return this.options.find(option => {
104 return option.id === this.selected;
105 });
106 } else {
107 return {
108 text: this.placeholder,
109 }
110 }
111 },
112 activeObject() {
113 return this.options.find(option => {
114 return option.id === this.active;
115 });
116 },
117 },
118 watch: {
119 search() {
120 if (this.optionsFiltered(this.search).length && this.optionsFiltered(this.search).indexOf(this.activeObject) === -1)
121 this.active = this.optionsFiltered(this.search)[0].id;
122 },
123 },
124 methods: {
125 clearActive() {
126 this.active = null;
127 this.$emit('input', null);
128 },
129 optionsFiltered(search) {
130 return this.options.filter(option => {
131 return option.text.toLowerCase().includes(search);
132 });
133 },
134 clickUpdate(id) {
135 this.toggleEdit();
136 this.active = id;
137 this.$emit('input', id);
138 },
139 activeUpdate() {
140 this.toggleEdit();
141 this.$emit('input', this.active);
142 },
143 toggleEdit() {
144 if (this.edit === true) {
145 this.edit = false;
146 this.search = '';
147 Vue.nextTick(() => {
148 this.$refs.selected.focus();
149 });
150 } else {
151 this.edit = true;
152 Vue.nextTick(() => {
153 this.$refs.search.focus();
154 });
155 }
156 },
157 selectedUp() {
158 const up = this.optionsFiltered(this.search).indexOf(this.activeObject) - 1;
159 if (up > -1) this.active = this.optionsFiltered(this.search)[up].id;
160 },
161 selectedDown() {
162 const down = this.optionsFiltered(this.search).indexOf(this.activeObject) + 1;
163 if (down < this.optionsFiltered(this.search).length)
164 this.active = this.optionsFiltered(this.search)[down].id;
165 },
166 },
167 mounted() {
168 this.active = this.selected;
169 },
170 };
171</script>
172
173
174<style lang="scss" scoped>
175 .clear {
176 cursor: pointer;
177 }
178
179 .focuser {
180 position: fixed;
181 z-index: 1;
182 top: 0;
183 left: 0;
184 bottom: 0;
185 right: 0;
186 }
187
188 .select {
189 position: absolute;
190 z-index: 2;
191 min-width: 400px;
192
193 .badge {
194 align-items: center;
195 display: flex;
196 }
197 }
198
199 .options {
200 max-height: 200px;
201 overflow-y: scroll;
202 background-color: #fff;
203 }
204
205 .list-group-item {
206 padding: .25rem .5rem;
207 }
208</style>