| @@ -9,4 +9,5 @@ | |||||
| import './styles/main.scss'; | import './styles/main.scss'; | ||||
| import './scripts/nav.js'; | import './scripts/nav.js'; | ||||
| import './scripts/calendar.js'; | import './scripts/calendar.js'; | ||||
| import './scripts/stopwatch.js'; | |||||
| import './scripts/entries.js'; | import './scripts/entries.js'; | ||||
| @@ -25,6 +25,24 @@ function rowPrefix() { | |||||
| return 'row'; | return 'row'; | ||||
| } | } | ||||
| // ── Rate-Mode Radio Toggle ─────────────────────────────────────────────────── | |||||
| function initRateModeToggles() { | |||||
| document.addEventListener('change', e => { | |||||
| if (e.target.type !== 'radio') return; | |||||
| const container = e.target.closest('.rate-mode'); | |||||
| if (!container) return; | |||||
| const inputWrap = container.querySelector('.rate-mode__input'); | |||||
| if (!inputWrap) return; | |||||
| inputWrap.hidden = e.target.value !== 'custom'; | |||||
| if (e.target.value === 'custom') { | |||||
| inputWrap.querySelector('input')?.focus(); | |||||
| } | |||||
| }); | |||||
| } | |||||
| // ── Create-Formular ────────────────────────────────────────────────────────── | // ── Create-Formular ────────────────────────────────────────────────────────── | ||||
| function initCreateForm() { | function initCreateForm() { | ||||
| @@ -59,6 +77,13 @@ function resetCreateForm() { | |||||
| if (billable) billable.checked = true; | if (billable) billable.checked = true; | ||||
| const client = document.getElementById('create-client'); | const client = document.getElementById('create-client'); | ||||
| if (client) client.value = ''; | if (client) client.value = ''; | ||||
| const defaultRadio = document.querySelector('[name="create-rate-mode"][value="default"]'); | |||||
| if (defaultRadio) { | |||||
| defaultRadio.checked = true; | |||||
| const rateInput = defaultRadio.closest('.rate-mode')?.querySelector('.rate-mode__input'); | |||||
| if (rateInput) rateInput.hidden = true; | |||||
| } | |||||
| } | } | ||||
| async function createEntity() { | async function createEntity() { | ||||
| @@ -99,8 +124,13 @@ function buildCreateBody() { | |||||
| note: document.getElementById('create-note')?.value || null, | note: document.getElementById('create-note')?.value || null, | ||||
| }; | }; | ||||
| const rateMode = document.querySelector('[name="create-rate-mode"]:checked'); | |||||
| const rate = document.getElementById('create-rate'); | const rate = document.getElementById('create-rate'); | ||||
| if (rate) body.hourlyRate = rate.value || null; | |||||
| if (rateMode) { | |||||
| body.hourlyRate = rateMode.value === 'custom' && rate?.value ? rate.value : null; | |||||
| } else if (rate) { | |||||
| body.hourlyRate = rate.value || null; | |||||
| } | |||||
| const client = document.getElementById('create-client'); | const client = document.getElementById('create-client'); | ||||
| if (client) body.clientId = parseInt(client.value, 10) || null; | if (client) body.clientId = parseInt(client.value, 10) || null; | ||||
| @@ -182,8 +212,13 @@ function buildEditBody(row) { | |||||
| note: row.querySelector('.edit-note')?.value || null, | note: row.querySelector('.edit-note')?.value || null, | ||||
| }; | }; | ||||
| const rateMode = row.querySelector('.rate-mode input[type="radio"][value="custom"]'); | |||||
| const rate = row.querySelector('.edit-rate'); | const rate = row.querySelector('.edit-rate'); | ||||
| if (rate) body.hourlyRate = rate.value || null; | |||||
| if (rateMode) { | |||||
| body.hourlyRate = rateMode.checked && rate?.value ? rate.value : null; | |||||
| } else if (rate) { | |||||
| body.hourlyRate = rate.value || null; | |||||
| } | |||||
| const client = row.querySelector('.edit-client'); | const client = row.querySelector('.edit-client'); | ||||
| if (client) body.clientId = parseInt(client.value, 10) || null; | if (client) body.clientId = parseInt(client.value, 10) || null; | ||||
| @@ -216,6 +251,17 @@ function updateRowDisplay(row, data) { | |||||
| const editRate = row.querySelector('.edit-rate'); | const editRate = row.querySelector('.edit-rate'); | ||||
| if (editRate) editRate.value = data.hourlyRate ?? ''; | if (editRate) editRate.value = data.hourlyRate ?? ''; | ||||
| const rateMode = row.querySelector('.rate-mode'); | |||||
| if (rateMode) { | |||||
| const hasCustom = data.hourlyRate != null && data.hourlyRate !== ''; | |||||
| const defaultRadio = rateMode.querySelector('input[value="default"]'); | |||||
| const customRadio = rateMode.querySelector('input[value="custom"]'); | |||||
| const inputWrap = rateMode.querySelector('.rate-mode__input'); | |||||
| if (defaultRadio) defaultRadio.checked = !hasCustom; | |||||
| if (customRadio) customRadio.checked = hasCustom; | |||||
| if (inputWrap) inputWrap.hidden = !hasCustom; | |||||
| } | |||||
| const editBillable = row.querySelector('.edit-billable'); | const editBillable = row.querySelector('.edit-billable'); | ||||
| if (editBillable) editBillable.checked = !!data.billable; | if (editBillable) editBillable.checked = !!data.billable; | ||||
| } | } | ||||
| @@ -365,26 +411,57 @@ function buildRowHTML(data) { | |||||
| if (data.projectCount !== undefined) { | if (data.projectCount !== undefined) { | ||||
| const c = data.projectCount; | const c = data.projectCount; | ||||
| const hasCustomRate = data.hourlyRate != null && data.hourlyRate !== ''; | |||||
| metaHtml = `<span class="crud-row__meta">${c} ${c === 1 ? t('projectSingular') : t('projectPlural')}</span>`; | metaHtml = `<span class="crud-row__meta">${c} ${c === 1 ? t('projectSingular') : t('projectPlural')}</span>`; | ||||
| editFields = ` | editFields = ` | ||||
| <label class="entry-form__label">${t('labelName')}</label> | <label class="entry-form__label">${t('labelName')}</label> | ||||
| <div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div> | <div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div> | ||||
| <label class="entry-form__label">${t('labelRate')}</label> | <label class="entry-form__label">${t('labelRate')}</label> | ||||
| <div class="entry-form__field" style="gap:8px"> | |||||
| <input type="number" class="input edit-rate" style="width:100px" value="${esc(data.hourlyRate ?? '')}" step="0.01" min="0" /> | |||||
| <span style="color:#7a8a9a;font-size:0.875rem">€</span> | |||||
| <div class="entry-form__field"> | |||||
| <div class="rate-mode"> | |||||
| <label class="rate-mode__option"> | |||||
| <input type="radio" name="edit-rate-mode-${data.id}" value="default" ${!hasCustomRate ? 'checked' : ''} /> | |||||
| <span>${t('rateModeDefault')}</span> | |||||
| </label> | |||||
| <label class="rate-mode__option"> | |||||
| <input type="radio" name="edit-rate-mode-${data.id}" value="custom" ${hasCustomRate ? 'checked' : ''} /> | |||||
| <span>${t('rateModeCustom')}</span> | |||||
| </label> | |||||
| <div class="rate-mode__input"${!hasCustomRate ? ' hidden' : ''}> | |||||
| <input type="number" class="input input--rate edit-rate" value="${esc(data.hourlyRate ?? '')}" step="0.01" min="0" /> | |||||
| <span class="entry-form__unit">€</span> | |||||
| </div> | |||||
| </div> | |||||
| </div> | </div> | ||||
| <label class="entry-form__label">${t('labelNote')}</label> | <label class="entry-form__label">${t('labelNote')}</label> | ||||
| <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`; | <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`; | ||||
| } | } | ||||
| if (data.clientName !== undefined && data.projectCount === undefined) { | if (data.clientName !== undefined && data.projectCount === undefined) { | ||||
| const hasCustomRate = data.hourlyRate != null && data.hourlyRate !== ''; | |||||
| metaHtml = `<span class="crud-row__meta">${esc(data.clientName)}</span>`; | metaHtml = `<span class="crud-row__meta">${esc(data.clientName)}</span>`; | ||||
| editFields = ` | editFields = ` | ||||
| <label class="entry-form__label">${t('labelName')}</label> | <label class="entry-form__label">${t('labelName')}</label> | ||||
| <div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div> | <div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div> | ||||
| <label class="entry-form__label">${t('labelClient')}</label> | <label class="entry-form__label">${t('labelClient')}</label> | ||||
| <div class="entry-form__field"><select class="select edit-client">${buildClientOptions(data.clientId)}</select></div> | <div class="entry-form__field"><select class="select edit-client">${buildClientOptions(data.clientId)}</select></div> | ||||
| <label class="entry-form__label">${t('labelRate')}</label> | |||||
| <div class="entry-form__field"> | |||||
| <div class="rate-mode"> | |||||
| <label class="rate-mode__option"> | |||||
| <input type="radio" name="edit-rate-mode-${data.id}" value="default" ${!hasCustomRate ? 'checked' : ''} /> | |||||
| <span>${t('rateModeDefault')}</span> | |||||
| </label> | |||||
| <label class="rate-mode__option"> | |||||
| <input type="radio" name="edit-rate-mode-${data.id}" value="custom" ${hasCustomRate ? 'checked' : ''} /> | |||||
| <span>${t('rateModeCustom')}</span> | |||||
| </label> | |||||
| <div class="rate-mode__input"${!hasCustomRate ? ' hidden' : ''}> | |||||
| <input type="number" class="input input--rate edit-rate" value="${esc(data.hourlyRate ?? '')}" step="0.01" min="0" /> | |||||
| <span class="entry-form__unit">€</span> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <label class="entry-form__label">${t('labelNote')}</label> | <label class="entry-form__label">${t('labelNote')}</label> | ||||
| <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`; | <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`; | ||||
| } | } | ||||
| @@ -395,11 +472,16 @@ function buildRowHTML(data) { | |||||
| <div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div> | <div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div> | ||||
| <label class="entry-form__label">${t('labelBillable')}</label> | <label class="entry-form__label">${t('labelBillable')}</label> | ||||
| <div class="entry-form__field"> | <div class="entry-form__field"> | ||||
| <label style="display:flex;align-items:center;gap:8px;cursor:pointer"> | |||||
| <label class="crud-checkbox-label"> | |||||
| <input type="checkbox" class="edit-billable" ${data.billable ? 'checked' : ''} /> | <input type="checkbox" class="edit-billable" ${data.billable ? 'checked' : ''} /> | ||||
| <span style="font-size:0.875rem">${t('billableLabel')}</span> | |||||
| <span>${t('billableLabel')}</span> | |||||
| </label> | </label> | ||||
| </div> | </div> | ||||
| <label class="entry-form__label">${t('labelRate')}</label> | |||||
| <div class="entry-form__field entry-form__field--rate"> | |||||
| <input type="number" class="input input--rate edit-rate" value="${esc(data.hourlyRate ?? '')}" step="0.01" min="0" /> | |||||
| <span class="entry-form__unit">€</span> | |||||
| </div> | |||||
| <label class="entry-form__label">${t('labelNote')}</label> | <label class="entry-form__label">${t('labelNote')}</label> | ||||
| <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`; | <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`; | ||||
| } | } | ||||
| @@ -448,4 +530,5 @@ document.addEventListener('DOMContentLoaded', () => { | |||||
| initCreateForm(); | initCreateForm(); | ||||
| initList(); | initList(); | ||||
| initTabs(); | initTabs(); | ||||
| initRateModeToggles(); | |||||
| }); | }); | ||||
| @@ -2,6 +2,7 @@ | |||||
| import { parseAndValidate, initDurationBlurHandler } from './duration.js'; | import { parseAndValidate, initDurationBlurHandler } from './duration.js'; | ||||
| import { esc, createTranslator, ANIMATION_MS, FADE_MS, MINUTES_PER_DAY, removeWithAnimation, animateIn } from './utils.js'; | import { esc, createTranslator, ANIMATION_MS, FADE_MS, MINUTES_PER_DAY, removeWithAnimation, animateIn } from './utils.js'; | ||||
| import { STOPWATCH_SVG } from './stopwatch.js'; | |||||
| const LAST_PROJECT_KEY = 'tt_last_project_id'; | const LAST_PROJECT_KEY = 'tt_last_project_id'; | ||||
| const LAST_SERVICE_KEY = 'tt_last_service_id'; | const LAST_SERVICE_KEY = 'tt_last_service_id'; | ||||
| @@ -65,6 +66,9 @@ function buildEntryRowHTML(entry, animate = false) { | |||||
| ? `<span class="entry-row__badge">${esc(entry.durationFormatted)}</span> | ? `<span class="entry-row__badge">${esc(entry.durationFormatted)}</span> | ||||
| <span class="entry-row__lock-indicator" title="${t('invoicedTitle')}">${LOCK_SVG}</span>` | <span class="entry-row__lock-indicator" title="${t('invoicedTitle')}">${LOCK_SVG}</span>` | ||||
| : `<span class="entry-row__badge">${esc(entry.durationFormatted)}</span> | : `<span class="entry-row__badge">${esc(entry.durationFormatted)}</span> | ||||
| <button class="entry-row__btn entry-row__btn--stopwatch" title="${t('btnTimerToggle')}" data-action="timer-toggle"> | |||||
| ${STOPWATCH_SVG} | |||||
| </button> | |||||
| <button class="entry-row__btn entry-row__btn--edit" title="${t('btnEdit')}" data-action="edit"> | <button class="entry-row__btn entry-row__btn--edit" title="${t('btnEdit')}" data-action="edit"> | ||||
| <svg viewBox="0 0 16 16" fill="none"><path d="M11 2l3 3L5 14H2v-3L11 2z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg> | <svg viewBox="0 0 16 16" fill="none"><path d="M11 2l3 3L5 14H2v-3L11 2z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg> | ||||
| </button> | </button> | ||||
| @@ -186,10 +190,11 @@ class EntryManager { | |||||
| const action = actionEl.dataset.action; | const action = actionEl.dataset.action; | ||||
| if (row.dataset.invoiced === 'true' && (action === 'edit' || action === 'delete')) return; | if (row.dataset.invoiced === 'true' && (action === 'edit' || action === 'delete')) return; | ||||
| switch (action) { | switch (action) { | ||||
| case 'edit': this.openEdit(row); break; | |||||
| case 'delete': this.deleteEntry(row); break; | |||||
| case 'save': this.saveEdit(row); break; | |||||
| case 'cancel': this.closeEdit(row); break; | |||||
| case 'edit': this.openEdit(row); break; | |||||
| case 'delete': this.deleteEntry(row); break; | |||||
| case 'save': this.saveEdit(row); break; | |||||
| case 'cancel': this.closeEdit(row); break; | |||||
| case 'timer-toggle': window.stopwatchManager?.resumeEntry(parseInt(row.dataset.id, 10)); break; | |||||
| } | } | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -436,6 +441,7 @@ class EntryManager { | |||||
| }); | }); | ||||
| this.checkAutoEdit(); | this.checkAutoEdit(); | ||||
| window.stopwatchManager?.markActiveEntryRow(); | |||||
| } | } | ||||
| updateTotal(totalDuration) { | updateTotal(totalDuration) { | ||||
| @@ -0,0 +1,658 @@ | |||||
| // assets/scripts/stopwatch.js | |||||
| import { esc, createTranslator } from './utils.js'; | |||||
| const LS_KEY = 'tt_timer_state'; | |||||
| const LAST_PROJECT_KEY = 'tt_last_project_id'; | |||||
| const LAST_SERVICE_KEY = 'tt_last_service_id'; | |||||
| const t = createTranslator('STOPWATCH'); | |||||
| const STOPWATCH_SVG = `<svg viewBox="0 0 16 16" fill="none"><circle cx="8" cy="9" r="5.5" stroke="currentColor" stroke-width="1.3"/><path d="M8 6v3.5l2 1.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/><path d="M6.5 1.5h3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M8 1.5v2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M12.5 4.5l1-1" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`; | |||||
| function buildEntryLabel(entry) { | |||||
| if (!entry) return ''; | |||||
| let label = `${entry.projectName} (${entry.clientName})`; | |||||
| if (entry.serviceName) label += ` – ${entry.serviceName}`; | |||||
| return label; | |||||
| } | |||||
| async function apiCall(url, options = {}) { | |||||
| const res = await fetch(url, options); | |||||
| const ct = res.headers.get('content-type') || ''; | |||||
| if (!ct.includes('application/json')) { | |||||
| console.error(`[Stopwatch] ${url} returned non-JSON:`, res.status, ct); | |||||
| return { ok: false, status: res.status, data: null }; | |||||
| } | |||||
| const data = await res.json(); | |||||
| return { ok: res.ok, status: res.status, data }; | |||||
| } | |||||
| // ── Searchable Select ───────────────────────────────────────────────────────── | |||||
| class SearchableSelect { | |||||
| constructor(container) { | |||||
| this.container = container; | |||||
| this.value = ''; | |||||
| this.label = ''; | |||||
| this.groups = []; | |||||
| this.open = false; | |||||
| this.highlightIdx = -1; | |||||
| this.placeholder = container.dataset.placeholder || '...'; | |||||
| this.container.innerHTML = ` | |||||
| <button type="button" class="ss__trigger"> | |||||
| <span class="ss__value">${esc(this.placeholder)}</span> | |||||
| <svg class="ss__arrow" viewBox="0 0 10 6"><path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linecap="round"/></svg> | |||||
| </button> | |||||
| <div class="ss__dropdown" hidden> | |||||
| <input type="text" class="ss__search" placeholder="${esc(t('search'))}"> | |||||
| <div class="ss__list"></div> | |||||
| </div>`; | |||||
| this.trigger = container.querySelector('.ss__trigger'); | |||||
| this.valueEl = container.querySelector('.ss__value'); | |||||
| this.dropdown = container.querySelector('.ss__dropdown'); | |||||
| this.search = container.querySelector('.ss__search'); | |||||
| this.list = container.querySelector('.ss__list'); | |||||
| this.trigger.addEventListener('click', (e) => { | |||||
| e.stopPropagation(); | |||||
| this.toggle(); | |||||
| }); | |||||
| this.search.addEventListener('input', () => this.render()); | |||||
| this.search.addEventListener('keydown', (e) => this.onKeydown(e)); | |||||
| this.list.addEventListener('click', (e) => { | |||||
| const item = e.target.closest('[data-value]'); | |||||
| if (item) this.select(item.dataset.value, item.dataset.label); | |||||
| }); | |||||
| document.addEventListener('click', (e) => { | |||||
| if (this.open && !this.container.contains(e.target)) this.close(); | |||||
| }); | |||||
| } | |||||
| setGroups(groups) { | |||||
| this.groups = groups; | |||||
| } | |||||
| setValue(val) { | |||||
| for (const g of this.groups) { | |||||
| for (const item of g.items) { | |||||
| if (String(item.id) === String(val)) { | |||||
| this.value = String(item.id); | |||||
| this.label = item.name; | |||||
| this.valueEl.textContent = item.name; | |||||
| this.valueEl.classList.add('ss__value--selected'); | |||||
| return; | |||||
| } | |||||
| } | |||||
| } | |||||
| this.value = ''; | |||||
| this.label = ''; | |||||
| this.valueEl.textContent = this.placeholder; | |||||
| this.valueEl.classList.remove('ss__value--selected'); | |||||
| } | |||||
| getValue() { return this.value; } | |||||
| toggle() { | |||||
| this.open ? this.close() : this.openDropdown(); | |||||
| } | |||||
| openDropdown() { | |||||
| this.open = true; | |||||
| this.dropdown.hidden = false; | |||||
| this.search.value = ''; | |||||
| this.highlightIdx = -1; | |||||
| this.render(); | |||||
| this.search.focus(); | |||||
| } | |||||
| close() { | |||||
| this.open = false; | |||||
| this.dropdown.hidden = true; | |||||
| } | |||||
| select(val, label) { | |||||
| this.value = val; | |||||
| this.label = label; | |||||
| this.valueEl.textContent = label; | |||||
| this.valueEl.classList.add('ss__value--selected'); | |||||
| this.close(); | |||||
| } | |||||
| focus() { | |||||
| this.openDropdown(); | |||||
| } | |||||
| render() { | |||||
| const q = this.search.value.toLowerCase().trim(); | |||||
| let html = ''; | |||||
| let idx = 0; | |||||
| const visibleItems = []; | |||||
| for (const g of this.groups) { | |||||
| const filtered = g.items.filter(item => | |||||
| !q || item.name.toLowerCase().includes(q) || g.label.toLowerCase().includes(q) | |||||
| ); | |||||
| if (!filtered.length) continue; | |||||
| html += `<div class="ss__group">${esc(g.label)}</div>`; | |||||
| for (const item of filtered) { | |||||
| const active = String(item.id) === this.value ? ' ss__item--active' : ''; | |||||
| const hl = idx === this.highlightIdx ? ' ss__item--highlight' : ''; | |||||
| html += `<div class="ss__item${active}${hl}" data-value="${item.id}" data-label="${esc(item.name)}" data-idx="${idx}">${esc(item.name)}</div>`; | |||||
| visibleItems.push(item); | |||||
| idx++; | |||||
| } | |||||
| } | |||||
| this.list.innerHTML = html || `<div class="ss__empty">–</div>`; | |||||
| this.visibleCount = visibleItems.length; | |||||
| } | |||||
| onKeydown(e) { | |||||
| if (e.key === 'ArrowDown') { | |||||
| e.preventDefault(); | |||||
| this.highlightIdx = Math.min(this.highlightIdx + 1, this.visibleCount - 1); | |||||
| this.render(); | |||||
| this.scrollToHighlight(); | |||||
| } else if (e.key === 'ArrowUp') { | |||||
| e.preventDefault(); | |||||
| this.highlightIdx = Math.max(this.highlightIdx - 1, 0); | |||||
| this.render(); | |||||
| this.scrollToHighlight(); | |||||
| } else if (e.key === 'Enter') { | |||||
| e.preventDefault(); | |||||
| const el = this.list.querySelector(`[data-idx="${this.highlightIdx}"]`); | |||||
| if (el) this.select(el.dataset.value, el.dataset.label); | |||||
| } else if (e.key === 'Escape') { | |||||
| this.close(); | |||||
| } | |||||
| } | |||||
| scrollToHighlight() { | |||||
| const el = this.list.querySelector('.ss__item--highlight'); | |||||
| if (el) el.scrollIntoView({ block: 'nearest' }); | |||||
| } | |||||
| } | |||||
| // ── StopwatchManager ────────────────────────────────────────────────────────── | |||||
| class StopwatchManager { | |||||
| constructor() { | |||||
| this.toggle = document.getElementById('stopwatch-toggle'); | |||||
| this.popover = document.getElementById('stopwatch-popover'); | |||||
| this.display = document.getElementById('stopwatch-display'); | |||||
| this.startBtn = document.getElementById('stopwatch-start'); | |||||
| this.noteField = document.getElementById('stopwatch-note'); | |||||
| this.hamburgerBtn = document.getElementById('hamburger-stopwatch'); | |||||
| this.headerTime = document.getElementById('stopwatch-header-time'); | |||||
| this.projectSelect = null; | |||||
| this.serviceSelect = null; | |||||
| const projEl = document.getElementById('stopwatch-project'); | |||||
| const svcEl = document.getElementById('stopwatch-service'); | |||||
| if (projEl) this.projectSelect = new SearchableSelect(projEl); | |||||
| if (svcEl) this.serviceSelect = new SearchableSelect(svcEl); | |||||
| this.running = false; | |||||
| this.entryId = null; | |||||
| this.startedAt = null; | |||||
| this.baseDuration = 0; | |||||
| this.entryLabel = ''; | |||||
| this.tickInterval = null; | |||||
| this.originalTitle = document.title; | |||||
| this.cachedOptions = null; | |||||
| this.busy = false; | |||||
| if (!this.toggle) return; | |||||
| this.init(); | |||||
| } | |||||
| async init() { | |||||
| this.toggle.addEventListener('click', (e) => { | |||||
| e.stopPropagation(); | |||||
| this.handleToggleClick(); | |||||
| }); | |||||
| this.startBtn?.addEventListener('click', () => this.startNew()); | |||||
| document.addEventListener('click', (e) => { | |||||
| if (this.popover && !this.popover.hidden | |||||
| && !this.popover.contains(e.target) | |||||
| && !this.toggle.contains(e.target)) { | |||||
| this.closePopover(); | |||||
| } | |||||
| }); | |||||
| this.hamburgerBtn?.addEventListener('click', () => this.handleToggleClick()); | |||||
| this.loadFromLocalStorage(); | |||||
| await this.loadStatus(); | |||||
| } | |||||
| // ── Toggle ────────────────────────────────────────────────────────────────── | |||||
| handleToggleClick() { | |||||
| if (this.running) { | |||||
| this.stop(); | |||||
| } else { | |||||
| this.togglePopover(); | |||||
| } | |||||
| } | |||||
| async togglePopover() { | |||||
| if (!this.popover) return; | |||||
| if (!this.popover.hidden) { | |||||
| this.closePopover(); | |||||
| return; | |||||
| } | |||||
| await this.loadOptions(); | |||||
| this.populateSelects(); | |||||
| this.popover.hidden = false; | |||||
| this.toggle.setAttribute('aria-expanded', 'true'); | |||||
| this.projectSelect?.focus(); | |||||
| } | |||||
| closePopover() { | |||||
| if (!this.popover) return; | |||||
| this.popover.hidden = true; | |||||
| this.toggle.setAttribute('aria-expanded', 'false'); | |||||
| this.projectSelect?.close(); | |||||
| this.serviceSelect?.close(); | |||||
| } | |||||
| // ── Select-Builder ────────────────────────────────────────────────────────── | |||||
| populateSelects() { | |||||
| if (!this.cachedOptions) return; | |||||
| const lastProject = localStorage.getItem(LAST_PROJECT_KEY); | |||||
| const lastService = localStorage.getItem(LAST_SERVICE_KEY); | |||||
| if (this.projectSelect) { | |||||
| const groups = {}; | |||||
| (this.cachedOptions.projects ?? []).forEach(p => { | |||||
| if (!groups[p.clientName]) groups[p.clientName] = []; | |||||
| groups[p.clientName].push(p); | |||||
| }); | |||||
| this.projectSelect.setGroups( | |||||
| Object.entries(groups).map(([label, items]) => ({ label, items })) | |||||
| ); | |||||
| if (lastProject) this.projectSelect.setValue(lastProject); | |||||
| } | |||||
| if (this.serviceSelect) { | |||||
| const billable = (this.cachedOptions.services ?? []).filter(s => s.billable); | |||||
| const notBillable = (this.cachedOptions.services ?? []).filter(s => !s.billable); | |||||
| const groups = []; | |||||
| if (billable.length) groups.push({ label: t('billable'), items: billable }); | |||||
| if (notBillable.length) groups.push({ label: t('notBillable'), items: notBillable }); | |||||
| this.serviceSelect.setGroups(groups); | |||||
| if (lastService) this.serviceSelect.setValue(lastService); | |||||
| } | |||||
| } | |||||
| // ── API ───────────────────────────────────────────────────────────────────── | |||||
| async loadStatus() { | |||||
| try { | |||||
| const { ok, data } = await apiCall('/api/timer/status'); | |||||
| if (!ok || !data) { | |||||
| if (this.running) { | |||||
| this.running = false; | |||||
| this.clearLocalStorage(); | |||||
| this.applyStoppedState(); | |||||
| } | |||||
| return; | |||||
| } | |||||
| if (data.running && data.startedAt) { | |||||
| this.running = true; | |||||
| this.entryId = data.entry?.id ?? null; | |||||
| this.startedAt = new Date(data.startedAt).getTime(); | |||||
| this.baseDuration = data.entry?.duration ?? 0; | |||||
| this.entryLabel = buildEntryLabel(data.entry); | |||||
| this.saveToLocalStorage(); | |||||
| this.applyRunningState(); | |||||
| } else if (this.running) { | |||||
| this.running = false; | |||||
| this.clearLocalStorage(); | |||||
| this.applyStoppedState(); | |||||
| } | |||||
| } catch { | |||||
| if (this.running) { | |||||
| this.running = false; | |||||
| this.clearLocalStorage(); | |||||
| this.applyStoppedState(); | |||||
| } | |||||
| } | |||||
| } | |||||
| async loadOptions() { | |||||
| if (this.cachedOptions) return; | |||||
| try { | |||||
| const { ok, data } = await apiCall('/api/timer/options'); | |||||
| if (ok && data) this.cachedOptions = data; | |||||
| } catch { /* silent */ } | |||||
| } | |||||
| async startNew() { | |||||
| if (this.busy) return; | |||||
| const projectId = this.projectSelect?.getValue(); | |||||
| if (!projectId) { | |||||
| this.projectSelect?.focus(); | |||||
| return; | |||||
| } | |||||
| this.busy = true; | |||||
| this.startBtn.disabled = true; | |||||
| try { | |||||
| const serviceId = this.serviceSelect?.getValue(); | |||||
| const { ok, status, data } = await apiCall('/api/timer/start', { | |||||
| method: 'POST', | |||||
| headers: { 'Content-Type': 'application/json' }, | |||||
| body: JSON.stringify({ | |||||
| projectId: parseInt(projectId, 10), | |||||
| serviceId: serviceId ? parseInt(serviceId, 10) : null, | |||||
| note: this.noteField?.value || null, | |||||
| }), | |||||
| }); | |||||
| if (status === 409 && data) { | |||||
| const name = data.runningEntry | |||||
| ? `${data.runningEntry.clientName} / ${data.runningEntry.projectName}` | |||||
| : ''; | |||||
| const msg = t('confirmReplace').replace('%project%', name); | |||||
| if (!confirm(msg)) return; | |||||
| await this.forceStop(); | |||||
| this.busy = false; | |||||
| return this.startNew(); | |||||
| } | |||||
| if (!ok) { | |||||
| alert(t('errorStart') + (data?.error ? `\n${data.error}` : ` (HTTP ${status})`)); | |||||
| return; | |||||
| } | |||||
| this.running = true; | |||||
| this.entryId = data.entry.id; | |||||
| this.startedAt = new Date(data.entry.timerStartedAt).getTime(); | |||||
| this.baseDuration = data.entry.duration; | |||||
| this.entryLabel = buildEntryLabel(data.entry); | |||||
| this.saveToLocalStorage(); | |||||
| this.applyRunningState(); | |||||
| this.closePopover(); | |||||
| if (projectId) localStorage.setItem(LAST_PROJECT_KEY, projectId); | |||||
| if (serviceId) localStorage.setItem(LAST_SERVICE_KEY, serviceId); | |||||
| try { | |||||
| this.addEntryToDOM(data.entry, data.totalDuration); | |||||
| } catch (domEx) { | |||||
| console.error('[Stopwatch] addEntryToDOM error (non-fatal):', domEx); | |||||
| } | |||||
| } catch (ex) { | |||||
| console.error('[Stopwatch] startNew error:', ex); | |||||
| alert(t('errorStart') + `\n${ex.message}`); | |||||
| } finally { | |||||
| this.busy = false; | |||||
| if (this.startBtn) this.startBtn.disabled = false; | |||||
| } | |||||
| } | |||||
| async resumeEntry(entryId) { | |||||
| if (this.busy) return; | |||||
| if (this.running && this.entryId === entryId) { | |||||
| this.stop(); | |||||
| return; | |||||
| } | |||||
| this.busy = true; | |||||
| try { | |||||
| const { ok, status, data } = await apiCall(`/api/timer/start/${entryId}`, { | |||||
| method: 'POST', | |||||
| }); | |||||
| if (status === 409 && data) { | |||||
| const name = data.runningEntry | |||||
| ? `${data.runningEntry.clientName} / ${data.runningEntry.projectName}` | |||||
| : ''; | |||||
| const msg = t('confirmReplace').replace('%project%', name); | |||||
| if (!confirm(msg)) return; | |||||
| await this.forceStop(); | |||||
| this.busy = false; | |||||
| return this.resumeEntry(entryId); | |||||
| } | |||||
| if (!ok) { | |||||
| alert(t('errorStart') + (data?.error ? `\n${data.error}` : ` (HTTP ${status})`)); | |||||
| return; | |||||
| } | |||||
| this.running = true; | |||||
| this.entryId = data.entry.id; | |||||
| this.startedAt = new Date(data.entry.timerStartedAt).getTime(); | |||||
| this.baseDuration = data.entry.duration; | |||||
| this.entryLabel = buildEntryLabel(data.entry); | |||||
| this.saveToLocalStorage(); | |||||
| this.applyRunningState(); | |||||
| } catch (ex) { | |||||
| console.error('[Stopwatch] resumeEntry error:', ex); | |||||
| alert(t('errorStart') + `\n${ex.message}`); | |||||
| } finally { | |||||
| this.busy = false; | |||||
| } | |||||
| } | |||||
| async stop() { | |||||
| if (this.busy) return; | |||||
| this.busy = true; | |||||
| try { | |||||
| const { ok, status, data } = await apiCall('/api/timer/stop', { method: 'POST' }); | |||||
| if (!ok) { | |||||
| alert(t('errorStop') + (data?.error ? `\n${data.error}` : ` (HTTP ${status})`)); | |||||
| if (status === 404) { | |||||
| this.running = false; | |||||
| this.clearLocalStorage(); | |||||
| this.applyStoppedState(); | |||||
| } | |||||
| return; | |||||
| } | |||||
| const stoppedEntryId = this.entryId; | |||||
| this.running = false; | |||||
| this.clearLocalStorage(); | |||||
| this.applyStoppedState(); | |||||
| try { | |||||
| this.updateEntryInDOM(stoppedEntryId, data.entry, data.totalDuration); | |||||
| } catch (domEx) { | |||||
| console.error('[Stopwatch] updateEntryInDOM error (non-fatal):', domEx); | |||||
| } | |||||
| } catch (ex) { | |||||
| console.error('[Stopwatch] stop error:', ex); | |||||
| alert(t('errorStop') + `\n${ex.message}`); | |||||
| } finally { | |||||
| this.busy = false; | |||||
| } | |||||
| } | |||||
| async forceStop() { | |||||
| try { | |||||
| const stoppedId = this.entryId; | |||||
| const { ok, data } = await apiCall('/api/timer/stop', { method: 'POST' }); | |||||
| if (!ok) return; | |||||
| this.running = false; | |||||
| this.clearLocalStorage(); | |||||
| this.applyStoppedState(); | |||||
| try { | |||||
| this.updateEntryInDOM(stoppedId, data.entry, data.totalDuration); | |||||
| } catch { /* silent */ } | |||||
| } catch { /* silent */ } | |||||
| } | |||||
| // ── Timer-Tick ────────────────────────────────────────────────────────────── | |||||
| startTicking() { | |||||
| if (this.tickInterval) return; | |||||
| this.tick(); | |||||
| this.tickInterval = setInterval(() => this.tick(), 1000); | |||||
| } | |||||
| stopTicking() { | |||||
| if (this.tickInterval) { | |||||
| clearInterval(this.tickInterval); | |||||
| this.tickInterval = null; | |||||
| } | |||||
| document.title = this.originalTitle; | |||||
| if (this.display) this.display.textContent = '0:00'; | |||||
| if (this.headerTime) { | |||||
| this.headerTime.textContent = ''; | |||||
| this.headerTime.hidden = true; | |||||
| } | |||||
| } | |||||
| tick() { | |||||
| if (!this.startedAt) return; | |||||
| const elapsedSec = Math.floor((Date.now() - this.startedAt) / 1000); | |||||
| const totalSec = (this.baseDuration * 60) + elapsedSec; | |||||
| const h = Math.floor(totalSec / 3600); | |||||
| const m = Math.floor((totalSec % 3600) / 60); | |||||
| const s = totalSec % 60; | |||||
| const long = h > 0 | |||||
| ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` | |||||
| : `${m}:${String(s).padStart(2, '0')}`; | |||||
| const short = `${h}:${String(m).padStart(2, '0')}`; | |||||
| document.title = `${short} - ${this.originalTitle}`; | |||||
| if (this.display) this.display.textContent = long; | |||||
| if (this.headerTime) { | |||||
| this.headerTime.textContent = short; | |||||
| this.headerTime.hidden = false; | |||||
| } | |||||
| if (this.entryId) { | |||||
| const badge = document.querySelector(`#entry-${this.entryId} .entry-row__badge`); | |||||
| if (badge) badge.textContent = short; | |||||
| } | |||||
| } | |||||
| // ── Visual State ──────────────────────────────────────────────────────────── | |||||
| applyRunningState() { | |||||
| this.toggle?.classList.add('main-nav__stopwatch--running'); | |||||
| this.hamburgerBtn?.classList.add('hamburger-nav__stopwatch--running'); | |||||
| if (this.toggle && this.entryLabel) this.toggle.title = this.entryLabel; | |||||
| this.startTicking(); | |||||
| this.markActiveEntryRow(); | |||||
| } | |||||
| applyStoppedState() { | |||||
| this.toggle?.classList.remove('main-nav__stopwatch--running'); | |||||
| this.hamburgerBtn?.classList.remove('hamburger-nav__stopwatch--running'); | |||||
| if (this.toggle) this.toggle.title = t('title'); | |||||
| this.stopTicking(); | |||||
| this.entryId = null; | |||||
| this.startedAt = null; | |||||
| this.baseDuration = 0; | |||||
| this.entryLabel = ''; | |||||
| this.clearActiveEntryRows(); | |||||
| } | |||||
| markActiveEntryRow() { | |||||
| this.clearActiveEntryRows(); | |||||
| if (!this.entryId) return; | |||||
| const row = document.getElementById(`entry-${this.entryId}`); | |||||
| if (row) row.classList.add('entry-row--timer-active'); | |||||
| } | |||||
| clearActiveEntryRows() { | |||||
| document.querySelectorAll('.entry-row--timer-active') | |||||
| .forEach(el => el.classList.remove('entry-row--timer-active')); | |||||
| } | |||||
| // ── DOM Integration ───────────────────────────────────────────────────────── | |||||
| addEntryToDOM(entry, totalDuration) { | |||||
| if (!window.entryManager) return; | |||||
| window.entryManager.addEntryToDOM(entry); | |||||
| if (totalDuration) window.entryManager.updateTotal(totalDuration); | |||||
| this.markActiveEntryRow(); | |||||
| } | |||||
| updateEntryInDOM(entryId, entry, totalDuration) { | |||||
| if (!window.entryManager || !entryId) return; | |||||
| const row = document.getElementById(`entry-${entryId}`); | |||||
| if (row) { | |||||
| window.entryManager.updateRowDisplay(row, entry); | |||||
| row.dataset.duration = entry.duration; | |||||
| } | |||||
| if (totalDuration) window.entryManager.updateTotal(totalDuration); | |||||
| } | |||||
| // ── LocalStorage ──────────────────────────────────────────────────────────── | |||||
| saveToLocalStorage() { | |||||
| try { | |||||
| localStorage.setItem(LS_KEY, JSON.stringify({ | |||||
| entryId: this.entryId, | |||||
| startedAt: this.startedAt, | |||||
| baseDuration: this.baseDuration, | |||||
| entryLabel: this.entryLabel, | |||||
| })); | |||||
| } catch { /* silent */ } | |||||
| } | |||||
| loadFromLocalStorage() { | |||||
| try { | |||||
| const raw = localStorage.getItem(LS_KEY); | |||||
| if (!raw) return; | |||||
| const data = JSON.parse(raw); | |||||
| if (data?.startedAt) { | |||||
| this.running = true; | |||||
| this.entryId = data.entryId; | |||||
| this.startedAt = data.startedAt; | |||||
| this.baseDuration = data.baseDuration ?? 0; | |||||
| this.entryLabel = data.entryLabel ?? ''; | |||||
| this.applyRunningState(); | |||||
| } | |||||
| } catch { /* silent */ } | |||||
| } | |||||
| clearLocalStorage() { | |||||
| try { localStorage.removeItem(LS_KEY); } catch { /* silent */ } | |||||
| } | |||||
| // ── Public ───────────────────────────────────────────────────────────────── | |||||
| static get SVG() { return STOPWATCH_SVG; } | |||||
| isRunningForEntry(entryId) { | |||||
| return this.running && this.entryId === entryId; | |||||
| } | |||||
| } | |||||
| window.stopwatchManager = null; | |||||
| document.addEventListener('DOMContentLoaded', () => { | |||||
| window.stopwatchManager = new StopwatchManager(); | |||||
| }); | |||||
| export { STOPWATCH_SVG }; | |||||
| @@ -60,3 +60,37 @@ | |||||
| gap: $space-4; | gap: $space-4; | ||||
| padding-top: $space-2; | padding-top: $space-2; | ||||
| } | } | ||||
| // ─── Rate Mode (Radio: Default / Custom) ──────────────────────────────────── | |||||
| .rate-mode { | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| gap: $space-2; | |||||
| } | |||||
| .rate-mode__option { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| gap: $space-2; | |||||
| cursor: pointer; | |||||
| input[type='radio'] { | |||||
| accent-color: var(--color-primary); | |||||
| width: 15px; | |||||
| height: 15px; | |||||
| flex-shrink: 0; | |||||
| cursor: pointer; | |||||
| } | |||||
| span { | |||||
| font-size: $font-size-sm; | |||||
| color: $color-text-dark; | |||||
| } | |||||
| } | |||||
| .rate-mode__input { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| gap: $space-2; | |||||
| padding-left: 23px; | |||||
| } | |||||
| @@ -0,0 +1,283 @@ | |||||
| @use '../atoms/variables' as *; | |||||
| @use '../atoms/mixins' as *; | |||||
| // ─── Nav-Button ────────────────────────────────────────────────────────────── | |||||
| .main-nav__stopwatch-wrap { | |||||
| position: relative; | |||||
| display: flex; | |||||
| align-items: center; | |||||
| margin: 0 $space-3; | |||||
| } | |||||
| .main-nav__stopwatch { | |||||
| @include icon-btn(auto, $radius-pill); | |||||
| color: rgba($color-white, 0.65); | |||||
| margin: 0 $space-1; | |||||
| position: relative; | |||||
| gap: $space-1; | |||||
| padding: 0 $space-1; | |||||
| height: 32px; | |||||
| min-width: 32px; | |||||
| svg { width: 16px; height: 16px; position: relative; z-index: 1; flex-shrink: 0; } | |||||
| &:hover { color: $color-white; background: var(--header-overlay); } | |||||
| &--running { | |||||
| color: $color-success; | |||||
| padding: 0 $space-2 0 $space-1; | |||||
| &:hover { color: $color-success; } | |||||
| &::before { | |||||
| content: ''; | |||||
| position: absolute; | |||||
| top: 1px; | |||||
| left: 1px; | |||||
| width: 28px; | |||||
| height: 28px; | |||||
| border-radius: 50%; | |||||
| border: 2px solid transparent; | |||||
| border-top-color: $color-success; | |||||
| border-right-color: $color-success; | |||||
| animation: stopwatch-spin 1s linear infinite; | |||||
| } | |||||
| &::after { | |||||
| content: ''; | |||||
| position: absolute; | |||||
| top: 1px; | |||||
| left: 1px; | |||||
| width: 28px; | |||||
| height: 28px; | |||||
| border-radius: 50%; | |||||
| border: 2px solid rgba($color-success, 0.2); | |||||
| } | |||||
| } | |||||
| } | |||||
| .main-nav__stopwatch-time { | |||||
| font-size: $font-size-xs; | |||||
| font-weight: $font-weight-bold; | |||||
| font-variant-numeric: tabular-nums; | |||||
| color: $color-success; | |||||
| white-space: nowrap; | |||||
| } | |||||
| @keyframes stopwatch-spin { | |||||
| to { transform: rotate(360deg); } | |||||
| } | |||||
| // ─── Popover ───────────────────────────────────────────────────────────────── | |||||
| .stopwatch-popover { | |||||
| position: absolute; | |||||
| top: 40px; | |||||
| left: 50%; | |||||
| transform: translateX(-50%); | |||||
| z-index: 300; | |||||
| @include card; | |||||
| padding: $space-4; | |||||
| width: 300px; | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| gap: $space-3; | |||||
| box-shadow: $shadow-calendar; | |||||
| &::before { | |||||
| content: ''; | |||||
| position: absolute; | |||||
| top: -6px; | |||||
| left: 50%; | |||||
| transform: translateX(-50%) rotate(45deg); | |||||
| width: 12px; | |||||
| height: 12px; | |||||
| background: $color-card-white; | |||||
| border-radius: 2px 0 0 0; | |||||
| } | |||||
| } | |||||
| .stopwatch-popover__timer { | |||||
| font-size: $font-size-xl; | |||||
| font-weight: $font-weight-bold; | |||||
| color: $color-text-dark; | |||||
| text-align: center; | |||||
| font-variant-numeric: tabular-nums; | |||||
| letter-spacing: 0.02em; | |||||
| } | |||||
| .stopwatch-popover__form { | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| gap: $space-2; | |||||
| } | |||||
| .stopwatch-popover__actions { | |||||
| display: flex; | |||||
| gap: $space-2; | |||||
| margin-top: $space-1; | |||||
| .btn { flex: 1; } | |||||
| } | |||||
| // ─── Entry-Row Stopwatch-Button ────────────────────────────────────────────── | |||||
| .entry-row__btn--stopwatch { | |||||
| color: $color-text-muted; | |||||
| &:hover { | |||||
| background: rgba($color-success, 0.12); | |||||
| color: $color-success; | |||||
| } | |||||
| } | |||||
| .entry-row--timer-active { | |||||
| border-left: 3px solid $color-success; | |||||
| .entry-row__btn--stopwatch { | |||||
| color: $color-success; | |||||
| opacity: 1; | |||||
| position: relative; | |||||
| &::before { | |||||
| content: ''; | |||||
| position: absolute; | |||||
| inset: 1px; | |||||
| border-radius: 50%; | |||||
| border: 2px solid transparent; | |||||
| border-top-color: $color-success; | |||||
| border-right-color: $color-success; | |||||
| animation: stopwatch-spin 1s linear infinite; | |||||
| } | |||||
| &::after { | |||||
| content: ''; | |||||
| position: absolute; | |||||
| inset: 1px; | |||||
| border-radius: 50%; | |||||
| border: 2px solid rgba($color-success, 0.15); | |||||
| } | |||||
| } | |||||
| } | |||||
| // ─── Searchable Select ─────────────────────────────────────────────────────── | |||||
| .searchable-select { | |||||
| position: relative; | |||||
| } | |||||
| .ss__trigger { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: space-between; | |||||
| width: 100%; | |||||
| padding: $space-2 $space-3; | |||||
| background: $color-input-bg; | |||||
| border: 1px solid $color-input-border; | |||||
| border-radius: $radius-sm; | |||||
| cursor: pointer; | |||||
| font-size: $font-size-sm; | |||||
| color: $color-text-base; | |||||
| text-align: left; | |||||
| transition: border-color $transition-fast; | |||||
| &:hover { border-color: var(--color-primary); } | |||||
| } | |||||
| .ss__value { | |||||
| @include text-truncate; | |||||
| flex: 1; | |||||
| color: $color-text-muted; | |||||
| &--selected { color: $color-text-dark; } | |||||
| } | |||||
| .ss__arrow { | |||||
| width: 10px; | |||||
| height: 10px; | |||||
| flex-shrink: 0; | |||||
| margin-left: $space-2; | |||||
| color: $color-text-muted; | |||||
| } | |||||
| .ss__dropdown { | |||||
| position: absolute; | |||||
| top: calc(100% + 4px); | |||||
| left: 0; | |||||
| right: 0; | |||||
| z-index: 400; | |||||
| background: $color-card-white; | |||||
| border: 1px solid $color-border; | |||||
| border-radius: $radius-md; | |||||
| box-shadow: $shadow-calendar; | |||||
| overflow: hidden; | |||||
| } | |||||
| .ss__search { | |||||
| display: block; | |||||
| width: 100%; | |||||
| padding: $space-2 $space-3; | |||||
| border: none; | |||||
| border-bottom: 1px solid $color-border; | |||||
| font-size: $font-size-sm; | |||||
| outline: none; | |||||
| background: $color-card; | |||||
| &::placeholder { color: $color-text-light; } | |||||
| } | |||||
| .ss__list { | |||||
| max-height: 200px; | |||||
| overflow-y: auto; | |||||
| } | |||||
| .ss__group { | |||||
| padding: $space-2 $space-3 $space-1; | |||||
| font-size: $font-size-xs; | |||||
| font-weight: $font-weight-bold; | |||||
| color: $color-text-dark; | |||||
| text-transform: uppercase; | |||||
| letter-spacing: 0.03em; | |||||
| &:not(:first-child) { border-top: 1px solid rgba($color-border, 0.5); } | |||||
| } | |||||
| .ss__item { | |||||
| padding: $space-1 $space-3 $space-1 $space-5; | |||||
| font-size: $font-size-sm; | |||||
| color: $color-text-base; | |||||
| cursor: pointer; | |||||
| &:hover, | |||||
| &--highlight { background: rgba(var(--color-primary-rgb), 0.08); } | |||||
| &--active { color: var(--color-primary); font-weight: $font-weight-medium; } | |||||
| } | |||||
| .ss__empty { | |||||
| padding: $space-3; | |||||
| text-align: center; | |||||
| color: $color-text-light; | |||||
| font-size: $font-size-sm; | |||||
| } | |||||
| // ─── Hamburger-Nav Stopwatch ───────────────────────────────────────────────── | |||||
| .hamburger-nav__stopwatch { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| gap: $space-2; | |||||
| padding: $space-2 $space-4; | |||||
| color: $color-text-base; | |||||
| background: none; | |||||
| border: none; | |||||
| cursor: pointer; | |||||
| font-size: $font-size-sm; | |||||
| width: 100%; | |||||
| text-align: left; | |||||
| svg { width: 14px; height: 14px; flex-shrink: 0; } | |||||
| &:hover { background: rgba(0, 0, 0, 0.04); } | |||||
| &--running { | |||||
| color: $color-success; | |||||
| font-weight: $font-weight-medium; | |||||
| } | |||||
| } | |||||
| @@ -18,6 +18,7 @@ | |||||
| @use 'components/register'; | @use 'components/register'; | ||||
| @use 'components/account'; | @use 'components/account'; | ||||
| @use 'components/team'; | @use 'components/team'; | ||||
| @use 'components/stopwatch'; | |||||
| // ─── Sections ───────────────────────────────────────────────────────────────── | // ─── Sections ───────────────────────────────────────────────────────────────── | ||||
| @use 'sections/timetracking'; | @use 'sections/timetracking'; | ||||
| @@ -87,16 +87,17 @@ class SeedCommand extends Command | |||||
| // ── Tenant: Leistungen ──────────────────────────────────────────────── | // ── Tenant: Leistungen ──────────────────────────────────────────────── | ||||
| $serviceData = [ | $serviceData = [ | ||||
| ['name' => 'Frontend-Entwicklung', 'billable' => true], | |||||
| ['name' => 'Software-Entwicklung', 'billable' => true], | |||||
| ['name' => 'Meeting', 'billable' => true], | |||||
| ['name' => 'Design', 'billable' => true], | |||||
| ['name' => 'Intern', 'billable' => false], | |||||
| ['name' => 'Frontend-Entwicklung', 'billable' => true, 'hourlyRate' => '95.00'], | |||||
| ['name' => 'Software-Entwicklung', 'billable' => true, 'hourlyRate' => '110.00'], | |||||
| ['name' => 'Meeting', 'billable' => true, 'hourlyRate' => '85.00'], | |||||
| ['name' => 'Design', 'billable' => true, 'hourlyRate' => '100.00'], | |||||
| ['name' => 'Intern', 'billable' => false, 'hourlyRate' => null], | |||||
| ]; | ]; | ||||
| foreach ($serviceData as $data) { | foreach ($serviceData as $data) { | ||||
| $service = new Service(); | $service = new Service(); | ||||
| $service->setName($data['name']); | $service->setName($data['name']); | ||||
| $service->setBillable($data['billable']); | $service->setBillable($data['billable']); | ||||
| $service->setHourlyRate($data['hourlyRate']); | |||||
| $this->tenantEm->persist($service); | $this->tenantEm->persist($service); | ||||
| } | } | ||||
| @@ -170,16 +171,17 @@ class SeedCommand extends Command | |||||
| // ── Tenant: Leistungen ──────────────────────────────────────────────── | // ── Tenant: Leistungen ──────────────────────────────────────────────── | ||||
| $serviceData2 = [ | $serviceData2 = [ | ||||
| ['name' => 'Beratung', 'billable' => true], | |||||
| ['name' => 'Projektmanagement', 'billable' => true], | |||||
| ['name' => 'Design', 'billable' => true], | |||||
| ['name' => 'Produktion', 'billable' => true], | |||||
| ['name' => 'Intern', 'billable' => false], | |||||
| ['name' => 'Beratung', 'billable' => true, 'hourlyRate' => '90.00'], | |||||
| ['name' => 'Projektmanagement', 'billable' => true, 'hourlyRate' => '100.00'], | |||||
| ['name' => 'Design', 'billable' => true, 'hourlyRate' => '95.00'], | |||||
| ['name' => 'Produktion', 'billable' => true, 'hourlyRate' => '85.00'], | |||||
| ['name' => 'Intern', 'billable' => false, 'hourlyRate' => null], | |||||
| ]; | ]; | ||||
| foreach ($serviceData2 as $data) { | foreach ($serviceData2 as $data) { | ||||
| $service = new Service(); | $service = new Service(); | ||||
| $service->setName($data['name']); | $service->setName($data['name']); | ||||
| $service->setBillable($data['billable']); | $service->setBillable($data['billable']); | ||||
| $service->setHourlyRate($data['hourlyRate']); | |||||
| $this->tenantEm->persist($service); | $this->tenantEm->persist($service); | ||||
| } | } | ||||
| @@ -53,6 +53,7 @@ class ProjectController extends AbstractController | |||||
| $project = new Project(); | $project = new Project(); | ||||
| $project->setName(trim($data['name'])); | $project->setName(trim($data['name'])); | ||||
| $project->setClient($client); | $project->setClient($client); | ||||
| $project->setHourlyRate(!empty($data['hourlyRate']) ? $data['hourlyRate'] : null); | |||||
| $project->setNote(!empty($data['note']) ? $data['note'] : null); | $project->setNote(!empty($data['note']) ? $data['note'] : null); | ||||
| $this->em->persist($project); | $this->em->persist($project); | ||||
| @@ -78,6 +79,7 @@ class ProjectController extends AbstractController | |||||
| $project->setName(trim($data['name'])); | $project->setName(trim($data['name'])); | ||||
| $project->setClient($client); | $project->setClient($client); | ||||
| $project->setHourlyRate(!empty($data['hourlyRate']) ? $data['hourlyRate'] : null); | |||||
| $project->setNote(!empty($data['note']) ? $data['note'] : null); | $project->setNote(!empty($data['note']) ? $data['note'] : null); | ||||
| $this->em->flush(); | $this->em->flush(); | ||||
| @@ -141,6 +143,7 @@ class ProjectController extends AbstractController | |||||
| 'name' => $project->getName(), | 'name' => $project->getName(), | ||||
| 'clientId' => $project->getClient()->getId(), | 'clientId' => $project->getClient()->getId(), | ||||
| 'clientName' => $project->getClient()->getName(), | 'clientName' => $project->getClient()->getName(), | ||||
| 'hourlyRate' => $project->getHourlyRate(), | |||||
| 'note' => $project->getNote(), | 'note' => $project->getNote(), | ||||
| 'archived' => $project->isArchived(), | 'archived' => $project->isArchived(), | ||||
| ]; | ]; | ||||
| @@ -48,6 +48,7 @@ class ServiceController extends AbstractController | |||||
| $service = new Service(); | $service = new Service(); | ||||
| $service->setName(trim($data['name'])); | $service->setName(trim($data['name'])); | ||||
| $service->setBillable((bool) ($data['billable'] ?? true)); | $service->setBillable((bool) ($data['billable'] ?? true)); | ||||
| $service->setHourlyRate(!empty($data['hourlyRate']) ? $data['hourlyRate'] : null); | |||||
| $service->setNote(!empty($data['note']) ? $data['note'] : null); | $service->setNote(!empty($data['note']) ? $data['note'] : null); | ||||
| $this->em->persist($service); | $this->em->persist($service); | ||||
| @@ -71,6 +72,7 @@ class ServiceController extends AbstractController | |||||
| $service->setName(trim($data['name'])); | $service->setName(trim($data['name'])); | ||||
| $service->setBillable((bool) ($data['billable'] ?? true)); | $service->setBillable((bool) ($data['billable'] ?? true)); | ||||
| $service->setHourlyRate(!empty($data['hourlyRate']) ? $data['hourlyRate'] : null); | |||||
| $service->setNote(!empty($data['note']) ? $data['note'] : null); | $service->setNote(!empty($data['note']) ? $data['note'] : null); | ||||
| $this->em->flush(); | $this->em->flush(); | ||||
| @@ -130,11 +132,12 @@ class ServiceController extends AbstractController | |||||
| private function serviceToArray(Service $service): array | private function serviceToArray(Service $service): array | ||||
| { | { | ||||
| return [ | return [ | ||||
| 'id' => $service->getId(), | |||||
| 'name' => $service->getName(), | |||||
| 'billable' => $service->isBillable(), | |||||
| 'note' => $service->getNote(), | |||||
| 'archived' => $service->isArchived(), | |||||
| 'id' => $service->getId(), | |||||
| 'name' => $service->getName(), | |||||
| 'billable' => $service->isBillable(), | |||||
| 'hourlyRate' => $service->getHourlyRate(), | |||||
| 'note' => $service->getNote(), | |||||
| 'archived' => $service->isArchived(), | |||||
| ]; | ]; | ||||
| } | } | ||||
| } | } | ||||
| @@ -223,6 +223,173 @@ class TimeTrackingController extends AbstractController | |||||
| return $this->json(['totalDuration' => $this->formatMinutes($totalMin)]); | return $this->json(['totalDuration' => $this->formatMinutes($totalMin)]); | ||||
| } | } | ||||
| // ── Timer-API ───────────────────────────────────────────────────────────── | |||||
| #[Route('/api/timer/status', name: 'api_timer_status', methods: ['GET'])] | |||||
| public function apiTimerStatus(): JsonResponse | |||||
| { | |||||
| /** @var User $user */ | |||||
| $user = $this->getUser(); | |||||
| $entry = $this->timeEntryRepo->findRunningTimerByUserId($user->getId()); | |||||
| return $this->json([ | |||||
| 'running' => $entry !== null, | |||||
| 'entry' => $entry?->toArray(), | |||||
| 'startedAt' => $entry?->getTimerStartedAt()?->format('c'), | |||||
| ]); | |||||
| } | |||||
| #[Route('/api/timer/options', name: 'api_timer_options', methods: ['GET'])] | |||||
| public function apiTimerOptions(): JsonResponse | |||||
| { | |||||
| $projects = $this->projectRepo->findAllWithClient(); | |||||
| $services = $this->serviceRepo->findAllOrderedByBillable(); | |||||
| return $this->json([ | |||||
| 'projects' => array_map(fn($p) => [ | |||||
| 'id' => $p->getId(), | |||||
| 'name' => $p->getName(), | |||||
| 'clientName' => $p->getClient()->getName(), | |||||
| ], $projects), | |||||
| 'services' => array_map(fn($s) => [ | |||||
| 'id' => $s->getId(), | |||||
| 'name' => $s->getName(), | |||||
| 'billable' => $s->isBillable(), | |||||
| ], $services), | |||||
| ]); | |||||
| } | |||||
| #[Route('/api/timer/start', name: 'api_timer_start', methods: ['POST'])] | |||||
| public function apiTimerStart(Request $request): JsonResponse | |||||
| { | |||||
| /** @var User $user */ | |||||
| $user = $this->getUser(); | |||||
| $running = $this->timeEntryRepo->findRunningTimerByUserId($user->getId()); | |||||
| if ($running) { | |||||
| return $this->json([ | |||||
| 'error' => 'timer_already_running', | |||||
| 'runningEntry' => $running->toArray(), | |||||
| ], 409); | |||||
| } | |||||
| $data = json_decode($request->getContent(), true); | |||||
| $project = $this->projectRepo->find($data['projectId'] ?? 0); | |||||
| if (!$project) { | |||||
| return $this->json(['error' => $this->translator->trans('app.error.project_not_found')], 400); | |||||
| } | |||||
| $service = null; | |||||
| if (!empty($data['serviceId'])) { | |||||
| $service = $this->serviceRepo->find($data['serviceId']); | |||||
| } | |||||
| $tz = new \DateTimeZone('Europe/Berlin'); | |||||
| $now = new \DateTimeImmutable('now', $tz); | |||||
| $entry = new TimeEntry(); | |||||
| $entry->setUserId($user->getId()); | |||||
| $entry->setProject($project); | |||||
| $entry->setService($service); | |||||
| $entry->setDate(new \DateTimeImmutable($data['date'] ?? 'today', $tz)); | |||||
| $entry->setDuration(0); | |||||
| $entry->setNote(!empty($data['note']) ? $data['note'] : null); | |||||
| $entry->setTimerStartedAt($now); | |||||
| $this->tenantEm->persist($entry); | |||||
| $this->tenantEm->flush(); | |||||
| $totalMin = $this->timeEntryRepo->sumDurationByDateAndUserId($entry->getDate(), $user->getId()); | |||||
| return $this->json([ | |||||
| 'entry' => $entry->toArray(), | |||||
| 'totalDuration' => $this->formatMinutes($totalMin), | |||||
| ], 201); | |||||
| } | |||||
| #[Route('/api/timer/start/{id}', name: 'api_timer_resume', methods: ['POST'])] | |||||
| public function apiTimerResume(int $id): JsonResponse | |||||
| { | |||||
| /** @var User $user */ | |||||
| $user = $this->getUser(); | |||||
| $entry = $this->timeEntryRepo->find($id); | |||||
| if (!$entry) { | |||||
| return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||||
| } | |||||
| if (!$this->roleHelper->isAdmin() && $entry->getUserId() !== $user->getId()) { | |||||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||||
| } | |||||
| if ($entry->isInvoiced()) { | |||||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||||
| } | |||||
| $running = $this->timeEntryRepo->findRunningTimerByUserId($user->getId()); | |||||
| if ($running) { | |||||
| return $this->json([ | |||||
| 'error' => 'timer_already_running', | |||||
| 'runningEntry' => $running->toArray(), | |||||
| ], 409); | |||||
| } | |||||
| $tz = new \DateTimeZone('Europe/Berlin'); | |||||
| $entry->setTimerStartedAt(new \DateTimeImmutable('now', $tz)); | |||||
| $this->tenantEm->flush(); | |||||
| return $this->json(['entry' => $entry->toArray()]); | |||||
| } | |||||
| #[Route('/api/timer/stop', name: 'api_timer_stop', methods: ['POST'])] | |||||
| public function apiTimerStop(): JsonResponse | |||||
| { | |||||
| /** @var User $user */ | |||||
| $user = $this->getUser(); | |||||
| $entry = $this->timeEntryRepo->findRunningTimerByUserId($user->getId()); | |||||
| if (!$entry) { | |||||
| return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404); | |||||
| } | |||||
| $tz = new \DateTimeZone('Europe/Berlin'); | |||||
| $now = new \DateTimeImmutable('now', $tz); | |||||
| $started = $entry->getTimerStartedAt(); | |||||
| $elapsed = max(0, (int) floor(($now->getTimestamp() - $started->getTimestamp()) / 60)); | |||||
| $interval = $this->tenantContext->getAccount()?->getTrackingInterval() ?? 1; | |||||
| $rounded = $this->roundToInterval($elapsed, $interval); | |||||
| $currentTotal = $this->timeEntryRepo->sumDurationByDateAndUserId($entry->getDate(), $user->getId()); | |||||
| $maxAdd = 1440 - $currentTotal; | |||||
| $rounded = min($rounded, $maxAdd); | |||||
| $entry->setDuration($entry->getDuration() + $rounded); | |||||
| $entry->setTimerStartedAt(null); | |||||
| $this->tenantEm->flush(); | |||||
| $totalMin = $this->timeEntryRepo->sumDurationByDateAndUserId($entry->getDate(), $user->getId()); | |||||
| return $this->json([ | |||||
| 'entry' => $entry->toArray(), | |||||
| 'totalDuration' => $this->formatMinutes($totalMin), | |||||
| 'elapsed' => $rounded, | |||||
| ]); | |||||
| } | |||||
| private function roundToInterval(int $minutes, int $interval): int | |||||
| { | |||||
| if ($minutes <= 0) { | |||||
| return 0; | |||||
| } | |||||
| if ($interval <= 1) { | |||||
| return $minutes; | |||||
| } | |||||
| return (int) (ceil($minutes / $interval) * $interval); | |||||
| } | |||||
| // ── Legacy-Routen ───────────────────────────────────────────────────────── | // ── Legacy-Routen ───────────────────────────────────────────────────────── | ||||
| #[Route('/day/{date}', name: 'timetracking_day')] | #[Route('/day/{date}', name: 'timetracking_day')] | ||||
| @@ -22,6 +22,9 @@ class Project | |||||
| #[ORM\JoinColumn(nullable: false)] | #[ORM\JoinColumn(nullable: false)] | ||||
| private ?Client $client = null; | private ?Client $client = null; | ||||
| #[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true)] | |||||
| private ?string $hourlyRate = null; | |||||
| #[ORM\Column(type: Types::TEXT, nullable: true)] | #[ORM\Column(type: Types::TEXT, nullable: true)] | ||||
| private ?string $note = null; | private ?string $note = null; | ||||
| @@ -36,6 +39,9 @@ class Project | |||||
| public function getClient(): ?Client { return $this->client; } | public function getClient(): ?Client { return $this->client; } | ||||
| public function setClient(?Client $client): static { $this->client = $client; return $this; } | public function setClient(?Client $client): static { $this->client = $client; return $this; } | ||||
| public function getHourlyRate(): ?string { return $this->hourlyRate; } | |||||
| public function setHourlyRate(?string $hourlyRate): static { $this->hourlyRate = $hourlyRate; return $this; } | |||||
| public function getNote(): ?string { return $this->note; } | public function getNote(): ?string { return $this->note; } | ||||
| public function setNote(?string $note): static { $this->note = $note; return $this; } | public function setNote(?string $note): static { $this->note = $note; return $this; } | ||||
| @@ -21,6 +21,9 @@ class Service | |||||
| #[ORM\Column] | #[ORM\Column] | ||||
| private bool $billable = true; | private bool $billable = true; | ||||
| #[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true)] | |||||
| private ?string $hourlyRate = null; | |||||
| #[ORM\Column(type: Types::TEXT, nullable: true)] | #[ORM\Column(type: Types::TEXT, nullable: true)] | ||||
| private ?string $note = null; | private ?string $note = null; | ||||
| @@ -35,6 +38,9 @@ class Service | |||||
| public function isBillable(): bool { return $this->billable; } | public function isBillable(): bool { return $this->billable; } | ||||
| public function setBillable(bool $billable): static { $this->billable = $billable; return $this; } | public function setBillable(bool $billable): static { $this->billable = $billable; return $this; } | ||||
| public function getHourlyRate(): ?string { return $this->hourlyRate; } | |||||
| public function setHourlyRate(?string $hourlyRate): static { $this->hourlyRate = $hourlyRate; return $this; } | |||||
| public function getNote(): ?string { return $this->note; } | public function getNote(): ?string { return $this->note; } | ||||
| public function setNote(?string $note): static { $this->note = $note; return $this; } | public function setNote(?string $note): static { $this->note = $note; return $this; } | ||||
| @@ -46,6 +46,9 @@ class TimeEntry | |||||
| #[ORM\Column] | #[ORM\Column] | ||||
| private \DateTimeImmutable $updatedAt; | private \DateTimeImmutable $updatedAt; | ||||
| #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] | |||||
| private ?\DateTimeImmutable $timerStartedAt = null; | |||||
| public function __construct() | public function __construct() | ||||
| { | { | ||||
| $this->createdAt = new \DateTimeImmutable(); | $this->createdAt = new \DateTimeImmutable(); | ||||
| @@ -87,6 +90,10 @@ class TimeEntry | |||||
| public function isInvoiced(): bool { return $this->invoiced; } | public function isInvoiced(): bool { return $this->invoiced; } | ||||
| public function setInvoiced(bool $invoiced): static { $this->invoiced = $invoiced; return $this; } | public function setInvoiced(bool $invoiced): static { $this->invoiced = $invoiced; return $this; } | ||||
| public function getTimerStartedAt(): ?\DateTimeImmutable { return $this->timerStartedAt; } | |||||
| public function setTimerStartedAt(?\DateTimeImmutable $at): static { $this->timerStartedAt = $at; return $this; } | |||||
| public function isTimerRunning(): bool { return $this->timerStartedAt !== null; } | |||||
| public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } | public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } | ||||
| public function getUpdatedAt(): \DateTimeImmutable { return $this->updatedAt; } | public function getUpdatedAt(): \DateTimeImmutable { return $this->updatedAt; } | ||||
| @@ -104,6 +111,7 @@ class TimeEntry | |||||
| 'serviceBillable' => $this->service?->isBillable(), | 'serviceBillable' => $this->service?->isBillable(), | ||||
| 'note' => $this->note, | 'note' => $this->note, | ||||
| 'invoiced' => $this->invoiced, | 'invoiced' => $this->invoiced, | ||||
| 'timerStartedAt' => $this->timerStartedAt?->format('c'), | |||||
| ]; | ]; | ||||
| } | } | ||||
| } | } | ||||
| @@ -46,6 +46,21 @@ class TimeEntryRepository extends ServiceEntityRepository | |||||
| return (int) $result; | return (int) $result; | ||||
| } | } | ||||
| public function findRunningTimerByUserId(int $userId): ?TimeEntry | |||||
| { | |||||
| return $this->createQueryBuilder('t') | |||||
| ->join('t.project', 'p') | |||||
| ->join('p.client', 'c') | |||||
| ->leftJoin('t.service', 's') | |||||
| ->addSelect('p', 'c', 's') | |||||
| ->where('t.userId = :userId') | |||||
| ->andWhere('t.timerStartedAt IS NOT NULL') | |||||
| ->setParameter('userId', $userId) | |||||
| ->setMaxResults(1) | |||||
| ->getQuery() | |||||
| ->getOneOrNullResult(); | |||||
| } | |||||
| // ── Zähler für abhängige Entitäten ──────────────────────────────────────── | // ── Zähler für abhängige Entitäten ──────────────────────────────────────── | ||||
| public function countByProject(Project $project): int | public function countByProject(Project $project): int | ||||
| @@ -196,8 +211,8 @@ class TimeEntryRepository extends ServiceEntityRepository | |||||
| public function sumRevenueFiltered(array $filters): float | public function sumRevenueFiltered(array $filters): float | ||||
| { | { | ||||
| $result = $this->buildFilteredQuery($filters) | $result = $this->buildFilteredQuery($filters) | ||||
| ->select('SUM(c.hourlyRate * t.duration / 60)') | |||||
| ->andWhere('c.hourlyRate IS NOT NULL') | |||||
| ->select('SUM(COALESCE(p.hourlyRate, c.hourlyRate, s.hourlyRate) * t.duration / 60)') | |||||
| ->andWhere('COALESCE(p.hourlyRate, c.hourlyRate, s.hourlyRate) IS NOT NULL') | |||||
| ->andWhere('(s IS NULL OR s.billable = :billable_rev)') | ->andWhere('(s IS NULL OR s.billable = :billable_rev)') | ||||
| ->setParameter('billable_rev', true) | ->setParameter('billable_rev', true) | ||||
| ->getQuery() | ->getQuery() | ||||
| @@ -64,9 +64,10 @@ class ReportExportService | |||||
| foreach ($entries as $entry) { | foreach ($entries as $entry) { | ||||
| $service = $entry->getService(); | $service = $entry->getService(); | ||||
| $client = $entry->getProject()?->getClient(); | |||||
| $project = $entry->getProject(); | |||||
| $client = $project?->getClient(); | |||||
| $billable = $service === null || $service->isBillable(); | $billable = $service === null || $service->isBillable(); | ||||
| $rate = $client?->getHourlyRate(); | |||||
| $rate = $project?->getHourlyRate() ?? $client?->getHourlyRate() ?? $service?->getHourlyRate(); | |||||
| $hours = $entry->getDuration() / 60; | $hours = $entry->getDuration() / 60; | ||||
| $revenue = ($billable && $rate !== null) ? $rate * $hours : null; | $revenue = ($billable && $rate !== null) ? $rate * $hours : null; | ||||
| @@ -35,6 +35,7 @@ class AppExtension extends AbstractExtension | |||||
| new TwigFunction('isCurrentUserMemberOrAdmin', [$this, 'isCurrentUserMemberOrAdmin']), | new TwigFunction('isCurrentUserMemberOrAdmin', [$this, 'isCurrentUserMemberOrAdmin']), | ||||
| new TwigFunction('getCurrentUserRole', [$this, 'getCurrentUserRole']), | new TwigFunction('getCurrentUserRole', [$this, 'getCurrentUserRole']), | ||||
| new TwigFunction('brandPalette', [$this, 'brandPalette']), | new TwigFunction('brandPalette', [$this, 'brandPalette']), | ||||
| new TwigFunction('trackingInterval', [$this, 'trackingInterval']), | |||||
| ]; | ]; | ||||
| } | } | ||||
| @@ -45,6 +46,8 @@ class AppExtension extends AbstractExtension | |||||
| return $this->brandColorService->paletteOrNull($hex); | return $this->brandColorService->paletteOrNull($hex); | ||||
| } | } | ||||
| public function trackingInterval(): int { return $this->tenantContext->getAccount()?->getTrackingInterval() ?? 1; } | |||||
| public function isCurrentUserAdmin(): bool { return $this->roleHelper->isAdmin(); } | public function isCurrentUserAdmin(): bool { return $this->roleHelper->isAdmin(); } | ||||
| public function isCurrentUserMemberOrAdmin(): bool { return $this->roleHelper->isMemberOrAdmin(); } | public function isCurrentUserMemberOrAdmin(): bool { return $this->roleHelper->isMemberOrAdmin(); } | ||||
| public function getCurrentUserRole(): string { return $this->roleHelper->getCurrentAccountUser()?->getRole() ?? ''; } | public function getCurrentUserRole(): string { return $this->roleHelper->getCurrentAccountUser()?->getRole() ?? ''; } | ||||
| @@ -0,0 +1,8 @@ | |||||
| {# templates/_atoms/icon-stopwatch.html.twig #} | |||||
| <svg viewBox="0 0 16 16" fill="none"> | |||||
| <circle cx="8" cy="9" r="5.5" stroke="currentColor" stroke-width="1.3"/> | |||||
| <path d="M8 6v3.5l2 1.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/> | |||||
| <path d="M6.5 1.5h3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/> | |||||
| <path d="M8 1.5v2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/> | |||||
| <path d="M12.5 4.5l1-1" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/> | |||||
| </svg> | |||||
| @@ -7,6 +7,30 @@ | |||||
| class="main-nav__item{% if currentRoute starts with 'timetracking' %} main-nav__item--active{% endif %}"> | class="main-nav__item{% if currentRoute starts with 'timetracking' %} main-nav__item--active{% endif %}"> | ||||
| {{ 'app.nav.time_tracking'|trans }} | {{ 'app.nav.time_tracking'|trans }} | ||||
| </a> | </a> | ||||
| {% if app.user %} | |||||
| <div class="main-nav__stopwatch-wrap"> | |||||
| <button class="main-nav__stopwatch" id="stopwatch-toggle" | |||||
| title="{{ 'app.stopwatch.title'|trans }}" | |||||
| aria-expanded="false"> | |||||
| {% include '_atoms/icon-stopwatch.html.twig' %} | |||||
| <span class="main-nav__stopwatch-time" id="stopwatch-header-time" hidden></span> | |||||
| </button> | |||||
| <div class="stopwatch-popover" id="stopwatch-popover" hidden> | |||||
| <div class="stopwatch-popover__timer" id="stopwatch-display">0:00</div> | |||||
| <div class="stopwatch-popover__form"> | |||||
| <div id="stopwatch-project" class="searchable-select" data-placeholder="{{ 'app.stopwatch.select_project'|trans }}"></div> | |||||
| <div id="stopwatch-service" class="searchable-select" data-placeholder="{{ 'app.stopwatch.select_service'|trans }}"></div> | |||||
| <textarea id="stopwatch-note" class="textarea" rows="2" | |||||
| placeholder="{{ 'app.entry.placeholder_note'|trans }}"></textarea> | |||||
| <div class="stopwatch-popover__actions"> | |||||
| <button type="button" class="btn btn-primary" id="stopwatch-start"> | |||||
| {{ 'app.stopwatch.btn_start'|trans }} | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| {% endif %} | |||||
| <a href="{{ path('report_times') }}" | <a href="{{ path('report_times') }}" | ||||
| class="main-nav__item{% if currentRoute starts with 'report' %} main-nav__item--active{% endif %}"> | class="main-nav__item{% if currentRoute starts with 'report' %} main-nav__item--active{% endif %}"> | ||||
| {{ 'app.nav.reports'|trans }} | {{ 'app.nav.reports'|trans }} | ||||
| @@ -53,6 +77,12 @@ | |||||
| class="hamburger-nav__item{% if currentRoute starts with 'timetracking' %} hamburger-nav__item--active{% endif %}"> | class="hamburger-nav__item{% if currentRoute starts with 'timetracking' %} hamburger-nav__item--active{% endif %}"> | ||||
| {{ 'app.nav.time_tracking'|trans }} | {{ 'app.nav.time_tracking'|trans }} | ||||
| </a> | </a> | ||||
| {% if app.user %} | |||||
| <button class="hamburger-nav__stopwatch" id="hamburger-stopwatch"> | |||||
| {% include '_atoms/icon-stopwatch.html.twig' %} | |||||
| <span>{{ 'app.stopwatch.title'|trans }}</span> | |||||
| </button> | |||||
| {% endif %} | |||||
| <a href="{{ path('report_times') }}" | <a href="{{ path('report_times') }}" | ||||
| class="hamburger-nav__item{% if currentRoute starts with 'report' %} hamburger-nav__item--active{% endif %}"> | class="hamburger-nav__item{% if currentRoute starts with 'report' %} hamburger-nav__item--active{% endif %}"> | ||||
| {{ 'app.nav.reports'|trans }} | {{ 'app.nav.reports'|trans }} | ||||
| @@ -86,4 +116,29 @@ | |||||
| {{ 'app.nav.logout'|trans }} | {{ 'app.nav.logout'|trans }} | ||||
| </a> | </a> | ||||
| </div> | </div> | ||||
| </div> | |||||
| </div> | |||||
| {% if app.user %} | |||||
| <script> | |||||
| window.STOPWATCH = { | |||||
| trackingInterval: {{ trackingInterval() }}, | |||||
| i18n: { | |||||
| title: {{ 'app.stopwatch.title'|trans|json_encode|raw }}, | |||||
| btnStart: {{ 'app.stopwatch.btn_start'|trans|json_encode|raw }}, | |||||
| btnStop: {{ 'app.stopwatch.btn_stop'|trans|json_encode|raw }}, | |||||
| resume: {{ 'app.stopwatch.resume'|trans|json_encode|raw }}, | |||||
| confirmReplace: {{ 'app.stopwatch.confirm_replace'|trans|json_encode|raw }}, | |||||
| confirmStop: {{ 'app.stopwatch.confirm_stop'|trans|json_encode|raw }}, | |||||
| errorStart: {{ 'app.stopwatch.error_start'|trans|json_encode|raw }}, | |||||
| errorStop: {{ 'app.stopwatch.error_stop'|trans|json_encode|raw }}, | |||||
| selectPh: {{ 'app.entry.select_placeholder'|trans|json_encode|raw }}, | |||||
| billable: {{ 'app.service.billable'|trans|json_encode|raw }}, | |||||
| notBillable: {{ 'app.service.not_billable'|trans|json_encode|raw }}, | |||||
| placeholderNote:{{ 'app.entry.placeholder_note'|trans|json_encode|raw }}, | |||||
| selectProject: {{ 'app.stopwatch.select_project'|trans|json_encode|raw }}, | |||||
| selectService: {{ 'app.stopwatch.select_service'|trans|json_encode|raw }}, | |||||
| search: {{ 'app.stopwatch.search'|trans|json_encode|raw }}, | |||||
| }, | |||||
| }; | |||||
| </script> | |||||
| {% endif %} | |||||
| @@ -33,6 +33,8 @@ window.CRUD = { | |||||
| groupNotBillable: {{ 'app.service.not_billable'|trans|json_encode|raw }}, | groupNotBillable: {{ 'app.service.not_billable'|trans|json_encode|raw }}, | ||||
| projectSingular: {{ 'app.crud.project_singular'|trans|json_encode|raw }}, | projectSingular: {{ 'app.crud.project_singular'|trans|json_encode|raw }}, | ||||
| projectPlural: {{ 'app.crud.project_plural'|trans|json_encode|raw }}, | projectPlural: {{ 'app.crud.project_plural'|trans|json_encode|raw }}, | ||||
| rateModeDefault: {{ 'app.crud.rate_mode_default'|trans|json_encode|raw }}, | |||||
| rateModeCustom: {{ 'app.crud.rate_mode_custom'|trans|json_encode|raw }}, | |||||
| }, | }, | ||||
| }; | }; | ||||
| </script> | </script> | ||||
| @@ -53,9 +55,21 @@ window.CRUD = { | |||||
| </div> | </div> | ||||
| <label class="entry-form__label">{{ 'app.crud.label_rate'|trans }}</label> | <label class="entry-form__label">{{ 'app.crud.label_rate'|trans }}</label> | ||||
| <div class="entry-form__field entry-form__field--rate"> | |||||
| <input type="number" id="create-rate" class="input input--rate" placeholder="0,00" step="0.01" min="0" /> | |||||
| <span class="entry-form__unit">€</span> | |||||
| <div class="entry-form__field"> | |||||
| <div class="rate-mode"> | |||||
| <label class="rate-mode__option"> | |||||
| <input type="radio" name="create-rate-mode" value="default" checked /> | |||||
| <span>{{ 'app.crud.rate_mode_default'|trans }}</span> | |||||
| </label> | |||||
| <label class="rate-mode__option"> | |||||
| <input type="radio" name="create-rate-mode" value="custom" /> | |||||
| <span>{{ 'app.crud.rate_mode_custom'|trans }}</span> | |||||
| </label> | |||||
| <div class="rate-mode__input" hidden> | |||||
| <input type="number" id="create-rate" class="input input--rate" placeholder="0,00" step="0.01" min="0" /> | |||||
| <span class="entry-form__unit">€</span> | |||||
| </div> | |||||
| </div> | |||||
| </div> | </div> | ||||
| <label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label> | <label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label> | ||||
| @@ -120,10 +134,24 @@ window.CRUD = { | |||||
| </div> | </div> | ||||
| <label class="entry-form__label">{{ 'app.crud.label_rate'|trans }}</label> | <label class="entry-form__label">{{ 'app.crud.label_rate'|trans }}</label> | ||||
| <div class="entry-form__field entry-form__field--rate"> | |||||
| <input type="number" class="input input--rate edit-rate" | |||||
| value="{{ client.hourlyRate|default('') }}" step="0.01" min="0" /> | |||||
| <span class="entry-form__unit">€</span> | |||||
| <div class="entry-form__field"> | |||||
| <div class="rate-mode"> | |||||
| <label class="rate-mode__option"> | |||||
| <input type="radio" name="edit-rate-mode-{{ client.id }}" value="default" | |||||
| {{ client.hourlyRate is null ? 'checked' : '' }} /> | |||||
| <span>{{ 'app.crud.rate_mode_default'|trans }}</span> | |||||
| </label> | |||||
| <label class="rate-mode__option"> | |||||
| <input type="radio" name="edit-rate-mode-{{ client.id }}" value="custom" | |||||
| {{ client.hourlyRate is not null ? 'checked' : '' }} /> | |||||
| <span>{{ 'app.crud.rate_mode_custom'|trans }}</span> | |||||
| </label> | |||||
| <div class="rate-mode__input"{{ client.hourlyRate is null ? ' hidden' : '' }}> | |||||
| <input type="number" class="input input--rate edit-rate" | |||||
| value="{{ client.hourlyRate|default('') }}" step="0.01" min="0" /> | |||||
| <span class="entry-form__unit">€</span> | |||||
| </div> | |||||
| </div> | |||||
| </div> | </div> | ||||
| <label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label> | <label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label> | ||||
| @@ -32,6 +32,8 @@ window.CRUD = { | |||||
| groupNotBillable: {{ 'app.service.not_billable'|trans|json_encode|raw }}, | groupNotBillable: {{ 'app.service.not_billable'|trans|json_encode|raw }}, | ||||
| projectSingular: {{ 'app.crud.project_singular'|trans|json_encode|raw }}, | projectSingular: {{ 'app.crud.project_singular'|trans|json_encode|raw }}, | ||||
| projectPlural: {{ 'app.crud.project_plural'|trans|json_encode|raw }}, | projectPlural: {{ 'app.crud.project_plural'|trans|json_encode|raw }}, | ||||
| rateModeDefault: {{ 'app.crud.rate_mode_default'|trans|json_encode|raw }}, | |||||
| rateModeCustom: {{ 'app.crud.rate_mode_custom'|trans|json_encode|raw }}, | |||||
| }, | }, | ||||
| }; | }; | ||||
| </script> | </script> | ||||
| @@ -61,6 +63,24 @@ window.CRUD = { | |||||
| </select> | </select> | ||||
| </div> | </div> | ||||
| <label class="entry-form__label">{{ 'app.crud.label_rate'|trans }}</label> | |||||
| <div class="entry-form__field"> | |||||
| <div class="rate-mode"> | |||||
| <label class="rate-mode__option"> | |||||
| <input type="radio" name="create-rate-mode" value="default" checked /> | |||||
| <span>{{ 'app.crud.rate_mode_default'|trans }}</span> | |||||
| </label> | |||||
| <label class="rate-mode__option"> | |||||
| <input type="radio" name="create-rate-mode" value="custom" /> | |||||
| <span>{{ 'app.crud.rate_mode_custom'|trans }}</span> | |||||
| </label> | |||||
| <div class="rate-mode__input" hidden> | |||||
| <input type="number" id="create-rate" class="input input--rate" placeholder="0,00" step="0.01" min="0" /> | |||||
| <span class="entry-form__unit">€</span> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label> | <label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label> | ||||
| <div class="entry-form__field"> | <div class="entry-form__field"> | ||||
| <textarea id="create-note" class="textarea" rows="2"></textarea> | <textarea id="create-note" class="textarea" rows="2"></textarea> | ||||
| @@ -88,6 +108,7 @@ window.CRUD = { | |||||
| data-archived="{{ project.isArchived() ? '1' : '0' }}" | data-archived="{{ project.isArchived() ? '1' : '0' }}" | ||||
| data-name="{{ project.name|e('html_attr') }}" | data-name="{{ project.name|e('html_attr') }}" | ||||
| data-client-id="{{ project.client.id }}" | data-client-id="{{ project.client.id }}" | ||||
| data-rate="{{ project.hourlyRate|default('') }}" | |||||
| data-note="{{ project.note|default('')|e('html_attr') }}"> | data-note="{{ project.note|default('')|e('html_attr') }}"> | ||||
| <div class="crud-row__display"> | <div class="crud-row__display"> | ||||
| @@ -132,6 +153,27 @@ window.CRUD = { | |||||
| </select> | </select> | ||||
| </div> | </div> | ||||
| <label class="entry-form__label">{{ 'app.crud.label_rate'|trans }}</label> | |||||
| <div class="entry-form__field"> | |||||
| <div class="rate-mode"> | |||||
| <label class="rate-mode__option"> | |||||
| <input type="radio" name="edit-rate-mode-{{ project.id }}" value="default" | |||||
| {{ project.hourlyRate is null ? 'checked' : '' }} /> | |||||
| <span>{{ 'app.crud.rate_mode_default'|trans }}</span> | |||||
| </label> | |||||
| <label class="rate-mode__option"> | |||||
| <input type="radio" name="edit-rate-mode-{{ project.id }}" value="custom" | |||||
| {{ project.hourlyRate is not null ? 'checked' : '' }} /> | |||||
| <span>{{ 'app.crud.rate_mode_custom'|trans }}</span> | |||||
| </label> | |||||
| <div class="rate-mode__input"{{ project.hourlyRate is null ? ' hidden' : '' }}> | |||||
| <input type="number" class="input input--rate edit-rate" | |||||
| value="{{ project.hourlyRate|default('') }}" step="0.01" min="0" /> | |||||
| <span class="entry-form__unit">€</span> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label> | <label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label> | ||||
| <div class="entry-form__field"> | <div class="entry-form__field"> | ||||
| <textarea class="textarea edit-note" rows="2">{{ project.note|default('') }}</textarea> | <textarea class="textarea edit-note" rows="2">{{ project.note|default('') }}</textarea> | ||||
| @@ -164,7 +164,7 @@ | |||||
| {% for entry in entries %} | {% for entry in entries %} | ||||
| {% set service = entry.service %} | {% set service = entry.service %} | ||||
| {% set billable = (service is null or service.billable) %} | {% set billable = (service is null or service.billable) %} | ||||
| {% set hourlyRate = entry.project.client.hourlyRate %} | |||||
| {% set hourlyRate = entry.project.hourlyRate ?? entry.project.client.hourlyRate ?? (service ? service.hourlyRate : null) %} | |||||
| {% set monthShort = monthsShort[entry.date|date('n') - 1] %} | {% set monthShort = monthsShort[entry.date|date('n') - 1] %} | ||||
| {% set canEdit = isAdmin or (entry.userId == currentUserId) %} | {% set canEdit = isAdmin or (entry.userId == currentUserId) %} | ||||
| @@ -31,6 +31,8 @@ | |||||
| groupNotBillable: {{ 'app.service.not_billable'|trans|json_encode|raw }}, | groupNotBillable: {{ 'app.service.not_billable'|trans|json_encode|raw }}, | ||||
| projectSingular: {{ 'app.crud.project_singular'|trans|json_encode|raw }}, | projectSingular: {{ 'app.crud.project_singular'|trans|json_encode|raw }}, | ||||
| projectPlural: {{ 'app.crud.project_plural'|trans|json_encode|raw }}, | projectPlural: {{ 'app.crud.project_plural'|trans|json_encode|raw }}, | ||||
| rateModeDefault: {{ 'app.crud.rate_mode_default'|trans|json_encode|raw }}, | |||||
| rateModeCustom: {{ 'app.crud.rate_mode_custom'|trans|json_encode|raw }}, | |||||
| }, | }, | ||||
| }; | }; | ||||
| </script> | </script> | ||||
| @@ -58,6 +60,12 @@ | |||||
| </label> | </label> | ||||
| </div> | </div> | ||||
| <label class="entry-form__label">{{ 'app.crud.label_rate'|trans }}</label> | |||||
| <div class="entry-form__field entry-form__field--rate"> | |||||
| <input type="number" id="create-rate" class="input input--rate" placeholder="0,00" step="0.01" min="0" /> | |||||
| <span class="entry-form__unit">€</span> | |||||
| </div> | |||||
| <label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label> | <label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label> | ||||
| <div class="entry-form__field"> | <div class="entry-form__field"> | ||||
| <textarea id="create-note" class="textarea" rows="2"></textarea> | <textarea id="create-note" class="textarea" rows="2"></textarea> | ||||
| @@ -95,6 +103,7 @@ | |||||
| data-archived="{{ service.isArchived() ? '1' : '0' }}" | data-archived="{{ service.isArchived() ? '1' : '0' }}" | ||||
| data-name="{{ service.name|e('html_attr') }}" | data-name="{{ service.name|e('html_attr') }}" | ||||
| data-billable="{{ service.billable ? '1' : '0' }}" | data-billable="{{ service.billable ? '1' : '0' }}" | ||||
| data-rate="{{ service.hourlyRate|default('') }}" | |||||
| data-note="{{ service.note|default('')|e('html_attr') }}"> | data-note="{{ service.note|default('')|e('html_attr') }}"> | ||||
| <div class="crud-row__display"> | <div class="crud-row__display"> | ||||
| @@ -134,6 +143,13 @@ | |||||
| </label> | </label> | ||||
| </div> | </div> | ||||
| <label class="entry-form__label">{{ 'app.crud.label_rate'|trans }}</label> | |||||
| <div class="entry-form__field entry-form__field--rate"> | |||||
| <input type="number" class="input input--rate edit-rate" | |||||
| value="{{ service.hourlyRate|default('') }}" step="0.01" min="0" /> | |||||
| <span class="entry-form__unit">€</span> | |||||
| </div> | |||||
| <label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label> | <label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label> | ||||
| <div class="entry-form__field"> | <div class="entry-form__field"> | ||||
| <textarea class="textarea edit-note" rows="2">{{ service.note|default('') }}</textarea> | <textarea class="textarea edit-note" rows="2">{{ service.note|default('') }}</textarea> | ||||
| @@ -25,6 +25,11 @@ | |||||
| {% include '_atoms/icon-lock.html.twig' %} | {% include '_atoms/icon-lock.html.twig' %} | ||||
| </span> | </span> | ||||
| {% else %} | {% else %} | ||||
| <button class="entry-row__btn entry-row__btn--stopwatch" | |||||
| title="{{ 'app.stopwatch.resume'|trans }}" | |||||
| data-action="timer-toggle"> | |||||
| {% include '_atoms/icon-stopwatch.html.twig' %} | |||||
| </button> | |||||
| <button class="entry-row__btn entry-row__btn--edit" | <button class="entry-row__btn entry-row__btn--edit" | ||||
| title="{{ 'app.entry.btn_edit'|trans }}" | title="{{ 'app.entry.btn_edit'|trans }}" | ||||
| data-action="edit"> | data-action="edit"> | ||||
| @@ -66,6 +66,8 @@ window.TT = { | |||||
| invoicedTitle: {{ 'app.entry.invoiced_title'|trans|json_encode|raw }}, | invoicedTitle: {{ 'app.entry.invoiced_title'|trans|json_encode|raw }}, | ||||
| noteShow: {{ 'app.entry.note_show'|trans|json_encode|raw }}, | noteShow: {{ 'app.entry.note_show'|trans|json_encode|raw }}, | ||||
| noteHide: {{ 'app.entry.note_hide'|trans|json_encode|raw }}, | noteHide: {{ 'app.entry.note_hide'|trans|json_encode|raw }}, | ||||
| btnTimerToggle: {{ 'app.stopwatch.resume'|trans|json_encode|raw }}, | |||||
| confirmTimerReplace: {{ 'app.stopwatch.confirm_replace'|trans|json_encode|raw }}, | |||||
| }, | }, | ||||
| }; | }; | ||||
| </script> | </script> | ||||
| @@ -110,6 +110,8 @@ app: | |||||
| label_note: "Bemerkung" | label_note: "Bemerkung" | ||||
| label_rate: "Stundensatz" | label_rate: "Stundensatz" | ||||
| label_client: "Kunde" | label_client: "Kunde" | ||||
| rate_mode_default: "Standard-Sätze der Leistungen" | |||||
| rate_mode_custom: "Eigener Stundensatz" | |||||
| tab_active: "Aktiv" | tab_active: "Aktiv" | ||||
| tab_archived: "Archiviert" | tab_archived: "Archiviert" | ||||
| btn_restore: "Wiederherstellen" | btn_restore: "Wiederherstellen" | ||||
| @@ -412,6 +414,20 @@ app: | |||||
| confirm_invalid: "Ungültiger Bestätigungslink." | confirm_invalid: "Ungültiger Bestätigungslink." | ||||
| confirm_expired: "Dieser Link ist abgelaufen (gültig 24 Stunden). Bitte registriere dich erneut." | confirm_expired: "Dieser Link ist abgelaufen (gültig 24 Stunden). Bitte registriere dich erneut." | ||||
| stopwatch: | |||||
| title: "Stoppuhr" | |||||
| btn_start: "Stoppuhr starten" | |||||
| btn_stop: "Stoppuhr stoppen" | |||||
| resume: "Timer fortsetzen" | |||||
| confirm_replace: "Es läuft bereits ein Timer für \"%project%\". Stoppen und neuen starten?" | |||||
| confirm_stop: "Timer stoppen?" | |||||
| error_start: "Fehler beim Starten des Timers." | |||||
| error_stop: "Fehler beim Stoppen des Timers." | |||||
| stopped: "Timer gestoppt" | |||||
| select_project: "Projekt wählen…" | |||||
| select_service: "Leistung wählen…" | |||||
| search: "Suchen…" | |||||
| home: | home: | ||||
| title: "spawntree Timetracker" | title: "spawntree Timetracker" | ||||
| btn_start: "Kostenlos starten" | btn_start: "Kostenlos starten" | ||||