diff --git a/httpdocs/assets/app.js b/httpdocs/assets/app.js index 585b02b..009c37a 100644 --- a/httpdocs/assets/app.js +++ b/httpdocs/assets/app.js @@ -9,4 +9,5 @@ import './styles/main.scss'; import './scripts/nav.js'; import './scripts/calendar.js'; +import './scripts/stopwatch.js'; import './scripts/entries.js'; diff --git a/httpdocs/assets/scripts/crud.js b/httpdocs/assets/scripts/crud.js index 190e4e1..aa26e0e 100644 --- a/httpdocs/assets/scripts/crud.js +++ b/httpdocs/assets/scripts/crud.js @@ -25,6 +25,24 @@ function rowPrefix() { 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 ────────────────────────────────────────────────────────── function initCreateForm() { @@ -59,6 +77,13 @@ function resetCreateForm() { if (billable) billable.checked = true; const client = document.getElementById('create-client'); 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() { @@ -99,8 +124,13 @@ function buildCreateBody() { note: document.getElementById('create-note')?.value || null, }; + const rateMode = document.querySelector('[name="create-rate-mode"]:checked'); 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'); if (client) body.clientId = parseInt(client.value, 10) || null; @@ -182,8 +212,13 @@ function buildEditBody(row) { note: row.querySelector('.edit-note')?.value || null, }; + const rateMode = row.querySelector('.rate-mode input[type="radio"][value="custom"]'); 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'); if (client) body.clientId = parseInt(client.value, 10) || null; @@ -216,6 +251,17 @@ function updateRowDisplay(row, data) { const editRate = row.querySelector('.edit-rate'); 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'); if (editBillable) editBillable.checked = !!data.billable; } @@ -365,26 +411,57 @@ function buildRowHTML(data) { if (data.projectCount !== undefined) { const c = data.projectCount; + const hasCustomRate = data.hourlyRate != null && data.hourlyRate !== ''; metaHtml = `${c} ${c === 1 ? t('projectSingular') : t('projectPlural')}`; editFields = `
-
- - +
+
+ + +
+ + +
+
`; } if (data.clientName !== undefined && data.projectCount === undefined) { + const hasCustomRate = data.hourlyRate != null && data.hourlyRate !== ''; metaHtml = `${esc(data.clientName)}`; editFields = `
+ +
+
+ + +
+ + +
+
+
`; } @@ -395,11 +472,16 @@ function buildRowHTML(data) {
-
+ +
+ + +
`; } @@ -448,4 +530,5 @@ document.addEventListener('DOMContentLoaded', () => { initCreateForm(); initList(); initTabs(); + initRateModeToggles(); }); diff --git a/httpdocs/assets/scripts/entries.js b/httpdocs/assets/scripts/entries.js index 836fe74..cbe00a3 100644 --- a/httpdocs/assets/scripts/entries.js +++ b/httpdocs/assets/scripts/entries.js @@ -2,6 +2,7 @@ import { parseAndValidate, initDurationBlurHandler } from './duration.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_SERVICE_KEY = 'tt_last_service_id'; @@ -65,6 +66,9 @@ function buildEntryRowHTML(entry, animate = false) { ? `${esc(entry.durationFormatted)} ${LOCK_SVG}` : `${esc(entry.durationFormatted)} + @@ -186,10 +190,11 @@ class EntryManager { const action = actionEl.dataset.action; if (row.dataset.invoiced === 'true' && (action === 'edit' || action === 'delete')) return; 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; } @@ -436,6 +441,7 @@ class EntryManager { }); this.checkAutoEdit(); + window.stopwatchManager?.markActiveEntryRow(); } updateTotal(totalDuration) { diff --git a/httpdocs/assets/scripts/stopwatch.js b/httpdocs/assets/scripts/stopwatch.js new file mode 100644 index 0000000..fe2bf25 --- /dev/null +++ b/httpdocs/assets/scripts/stopwatch.js @@ -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 = ``; + +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 = ` + + `; + + 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 += `
${esc(g.label)}
`; + for (const item of filtered) { + const active = String(item.id) === this.value ? ' ss__item--active' : ''; + const hl = idx === this.highlightIdx ? ' ss__item--highlight' : ''; + html += `
${esc(item.name)}
`; + visibleItems.push(item); + idx++; + } + } + + this.list.innerHTML = html || `
`; + 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 }; diff --git a/httpdocs/assets/styles/components/_entry-form.scss b/httpdocs/assets/styles/components/_entry-form.scss index 6e6e96c..9d60626 100644 --- a/httpdocs/assets/styles/components/_entry-form.scss +++ b/httpdocs/assets/styles/components/_entry-form.scss @@ -60,3 +60,37 @@ gap: $space-4; 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; +} diff --git a/httpdocs/assets/styles/components/_stopwatch.scss b/httpdocs/assets/styles/components/_stopwatch.scss new file mode 100644 index 0000000..0f81ad8 --- /dev/null +++ b/httpdocs/assets/styles/components/_stopwatch.scss @@ -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; + } +} diff --git a/httpdocs/assets/styles/main.scss b/httpdocs/assets/styles/main.scss index b1de747..e26f620 100644 --- a/httpdocs/assets/styles/main.scss +++ b/httpdocs/assets/styles/main.scss @@ -18,6 +18,7 @@ @use 'components/register'; @use 'components/account'; @use 'components/team'; +@use 'components/stopwatch'; // ─── Sections ───────────────────────────────────────────────────────────────── @use 'sections/timetracking'; diff --git a/httpdocs/src/Command/SeedCommand.php b/httpdocs/src/Command/SeedCommand.php index a7219a9..f6c098d 100644 --- a/httpdocs/src/Command/SeedCommand.php +++ b/httpdocs/src/Command/SeedCommand.php @@ -87,16 +87,17 @@ class SeedCommand extends Command // ── Tenant: Leistungen ──────────────────────────────────────────────── $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) { $service = new Service(); $service->setName($data['name']); $service->setBillable($data['billable']); + $service->setHourlyRate($data['hourlyRate']); $this->tenantEm->persist($service); } @@ -170,16 +171,17 @@ class SeedCommand extends Command // ── Tenant: Leistungen ──────────────────────────────────────────────── $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) { $service = new Service(); $service->setName($data['name']); $service->setBillable($data['billable']); + $service->setHourlyRate($data['hourlyRate']); $this->tenantEm->persist($service); } diff --git a/httpdocs/src/Controller/ProjectController.php b/httpdocs/src/Controller/ProjectController.php index 52aa10c..b2a817f 100644 --- a/httpdocs/src/Controller/ProjectController.php +++ b/httpdocs/src/Controller/ProjectController.php @@ -53,6 +53,7 @@ class ProjectController extends AbstractController $project = new Project(); $project->setName(trim($data['name'])); $project->setClient($client); + $project->setHourlyRate(!empty($data['hourlyRate']) ? $data['hourlyRate'] : null); $project->setNote(!empty($data['note']) ? $data['note'] : null); $this->em->persist($project); @@ -78,6 +79,7 @@ class ProjectController extends AbstractController $project->setName(trim($data['name'])); $project->setClient($client); + $project->setHourlyRate(!empty($data['hourlyRate']) ? $data['hourlyRate'] : null); $project->setNote(!empty($data['note']) ? $data['note'] : null); $this->em->flush(); @@ -141,6 +143,7 @@ class ProjectController extends AbstractController 'name' => $project->getName(), 'clientId' => $project->getClient()->getId(), 'clientName' => $project->getClient()->getName(), + 'hourlyRate' => $project->getHourlyRate(), 'note' => $project->getNote(), 'archived' => $project->isArchived(), ]; diff --git a/httpdocs/src/Controller/ServiceController.php b/httpdocs/src/Controller/ServiceController.php index db88b04..2a16f98 100644 --- a/httpdocs/src/Controller/ServiceController.php +++ b/httpdocs/src/Controller/ServiceController.php @@ -48,6 +48,7 @@ class ServiceController extends AbstractController $service = new Service(); $service->setName(trim($data['name'])); $service->setBillable((bool) ($data['billable'] ?? true)); + $service->setHourlyRate(!empty($data['hourlyRate']) ? $data['hourlyRate'] : null); $service->setNote(!empty($data['note']) ? $data['note'] : null); $this->em->persist($service); @@ -71,6 +72,7 @@ class ServiceController extends AbstractController $service->setName(trim($data['name'])); $service->setBillable((bool) ($data['billable'] ?? true)); + $service->setHourlyRate(!empty($data['hourlyRate']) ? $data['hourlyRate'] : null); $service->setNote(!empty($data['note']) ? $data['note'] : null); $this->em->flush(); @@ -130,11 +132,12 @@ class ServiceController extends AbstractController private function serviceToArray(Service $service): array { 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(), ]; } } diff --git a/httpdocs/src/Controller/TimeTrackingController.php b/httpdocs/src/Controller/TimeTrackingController.php index 9ca9ee9..023641c 100644 --- a/httpdocs/src/Controller/TimeTrackingController.php +++ b/httpdocs/src/Controller/TimeTrackingController.php @@ -223,6 +223,173 @@ class TimeTrackingController extends AbstractController 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 ───────────────────────────────────────────────────────── #[Route('/day/{date}', name: 'timetracking_day')] diff --git a/httpdocs/src/Entity/Tenant/Project.php b/httpdocs/src/Entity/Tenant/Project.php index d4210da..4b55bdf 100644 --- a/httpdocs/src/Entity/Tenant/Project.php +++ b/httpdocs/src/Entity/Tenant/Project.php @@ -22,6 +22,9 @@ class Project #[ORM\JoinColumn(nullable: false)] 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)] private ?string $note = null; @@ -36,6 +39,9 @@ class Project public function getClient(): ?Client { return $this->client; } 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 setNote(?string $note): static { $this->note = $note; return $this; } diff --git a/httpdocs/src/Entity/Tenant/Service.php b/httpdocs/src/Entity/Tenant/Service.php index 311bf85..f1cc726 100644 --- a/httpdocs/src/Entity/Tenant/Service.php +++ b/httpdocs/src/Entity/Tenant/Service.php @@ -21,6 +21,9 @@ class Service #[ORM\Column] 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)] private ?string $note = null; @@ -35,6 +38,9 @@ class Service public function isBillable(): bool { return $this->billable; } 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 setNote(?string $note): static { $this->note = $note; return $this; } diff --git a/httpdocs/src/Entity/Tenant/TimeEntry.php b/httpdocs/src/Entity/Tenant/TimeEntry.php index e5a40a6..72fc282 100644 --- a/httpdocs/src/Entity/Tenant/TimeEntry.php +++ b/httpdocs/src/Entity/Tenant/TimeEntry.php @@ -46,6 +46,9 @@ class TimeEntry #[ORM\Column] private \DateTimeImmutable $updatedAt; + #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] + private ?\DateTimeImmutable $timerStartedAt = null; + public function __construct() { $this->createdAt = new \DateTimeImmutable(); @@ -87,6 +90,10 @@ class TimeEntry public function isInvoiced(): bool { return $this->invoiced; } 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 getUpdatedAt(): \DateTimeImmutable { return $this->updatedAt; } @@ -104,6 +111,7 @@ class TimeEntry 'serviceBillable' => $this->service?->isBillable(), 'note' => $this->note, 'invoiced' => $this->invoiced, + 'timerStartedAt' => $this->timerStartedAt?->format('c'), ]; } } \ No newline at end of file diff --git a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php index 12564d6..f7b1a01 100644 --- a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php +++ b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php @@ -46,6 +46,21 @@ class TimeEntryRepository extends ServiceEntityRepository 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 ──────────────────────────────────────── public function countByProject(Project $project): int @@ -196,8 +211,8 @@ class TimeEntryRepository extends ServiceEntityRepository public function sumRevenueFiltered(array $filters): float { $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)') ->setParameter('billable_rev', true) ->getQuery() diff --git a/httpdocs/src/Service/ReportExportService.php b/httpdocs/src/Service/ReportExportService.php index 41613a5..7b7b6c5 100644 --- a/httpdocs/src/Service/ReportExportService.php +++ b/httpdocs/src/Service/ReportExportService.php @@ -64,9 +64,10 @@ class ReportExportService foreach ($entries as $entry) { $service = $entry->getService(); - $client = $entry->getProject()?->getClient(); + $project = $entry->getProject(); + $client = $project?->getClient(); $billable = $service === null || $service->isBillable(); - $rate = $client?->getHourlyRate(); + $rate = $project?->getHourlyRate() ?? $client?->getHourlyRate() ?? $service?->getHourlyRate(); $hours = $entry->getDuration() / 60; $revenue = ($billable && $rate !== null) ? $rate * $hours : null; diff --git a/httpdocs/src/Twig/AppExtension.php b/httpdocs/src/Twig/AppExtension.php index 7041c0b..0ab5f71 100644 --- a/httpdocs/src/Twig/AppExtension.php +++ b/httpdocs/src/Twig/AppExtension.php @@ -35,6 +35,7 @@ class AppExtension extends AbstractExtension new TwigFunction('isCurrentUserMemberOrAdmin', [$this, 'isCurrentUserMemberOrAdmin']), new TwigFunction('getCurrentUserRole', [$this, 'getCurrentUserRole']), new TwigFunction('brandPalette', [$this, 'brandPalette']), + new TwigFunction('trackingInterval', [$this, 'trackingInterval']), ]; } @@ -45,6 +46,8 @@ class AppExtension extends AbstractExtension 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 isCurrentUserMemberOrAdmin(): bool { return $this->roleHelper->isMemberOrAdmin(); } public function getCurrentUserRole(): string { return $this->roleHelper->getCurrentAccountUser()?->getRole() ?? ''; } diff --git a/httpdocs/templates/_atoms/icon-stopwatch.html.twig b/httpdocs/templates/_atoms/icon-stopwatch.html.twig new file mode 100644 index 0000000..8330255 --- /dev/null +++ b/httpdocs/templates/_atoms/icon-stopwatch.html.twig @@ -0,0 +1,8 @@ +{# templates/_atoms/icon-stopwatch.html.twig #} + + + + + + + diff --git a/httpdocs/templates/_sections/nav.html.twig b/httpdocs/templates/_sections/nav.html.twig index fa3e88c..261acce 100644 --- a/httpdocs/templates/_sections/nav.html.twig +++ b/httpdocs/templates/_sections/nav.html.twig @@ -7,6 +7,30 @@ class="main-nav__item{% if currentRoute starts with 'timetracking' %} main-nav__item--active{% endif %}"> {{ 'app.nav.time_tracking'|trans }} + {% if app.user %} + + {% endif %} {{ 'app.nav.reports'|trans }} @@ -53,6 +77,12 @@ class="hamburger-nav__item{% if currentRoute starts with 'timetracking' %} hamburger-nav__item--active{% endif %}"> {{ 'app.nav.time_tracking'|trans }} + {% if app.user %} + + {% endif %} {{ 'app.nav.reports'|trans }} @@ -86,4 +116,29 @@ {{ 'app.nav.logout'|trans }}
- \ No newline at end of file + + +{% if app.user %} + +{% endif %} \ No newline at end of file diff --git a/httpdocs/templates/client/index.html.twig b/httpdocs/templates/client/index.html.twig index 73e4a84..5ab1624 100644 --- a/httpdocs/templates/client/index.html.twig +++ b/httpdocs/templates/client/index.html.twig @@ -33,6 +33,8 @@ window.CRUD = { groupNotBillable: {{ 'app.service.not_billable'|trans|json_encode|raw }}, projectSingular: {{ 'app.crud.project_singular'|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 }}, }, }; @@ -53,9 +55,21 @@ window.CRUD = { -
- - +
+
+ + + +
@@ -120,10 +134,24 @@ window.CRUD = {
-
- - +
+
+ + +
+ + +
+
diff --git a/httpdocs/templates/project/index.html.twig b/httpdocs/templates/project/index.html.twig index 12f2a07..725c6fa 100644 --- a/httpdocs/templates/project/index.html.twig +++ b/httpdocs/templates/project/index.html.twig @@ -32,6 +32,8 @@ window.CRUD = { groupNotBillable: {{ 'app.service.not_billable'|trans|json_encode|raw }}, projectSingular: {{ 'app.crud.project_singular'|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 }}, }, }; @@ -61,6 +63,24 @@ window.CRUD = {
+ +
+
+ + + +
+
+
@@ -88,6 +108,7 @@ window.CRUD = { data-archived="{{ project.isArchived() ? '1' : '0' }}" data-name="{{ project.name|e('html_attr') }}" data-client-id="{{ project.client.id }}" + data-rate="{{ project.hourlyRate|default('') }}" data-note="{{ project.note|default('')|e('html_attr') }}">
@@ -132,6 +153,27 @@ window.CRUD = {
+ +
+
+ + +
+ + +
+
+
+
diff --git a/httpdocs/templates/report/times.html.twig b/httpdocs/templates/report/times.html.twig index 35c5f27..d81a311 100644 --- a/httpdocs/templates/report/times.html.twig +++ b/httpdocs/templates/report/times.html.twig @@ -164,7 +164,7 @@ {% for entry in entries %} {% set service = entry.service %} {% 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 canEdit = isAdmin or (entry.userId == currentUserId) %} diff --git a/httpdocs/templates/service/index.html.twig b/httpdocs/templates/service/index.html.twig index af934b5..839e090 100644 --- a/httpdocs/templates/service/index.html.twig +++ b/httpdocs/templates/service/index.html.twig @@ -31,6 +31,8 @@ groupNotBillable: {{ 'app.service.not_billable'|trans|json_encode|raw }}, projectSingular: {{ 'app.crud.project_singular'|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 }}, }, }; @@ -58,6 +60,12 @@
+ +
+ + +
+
@@ -95,6 +103,7 @@ data-archived="{{ service.isArchived() ? '1' : '0' }}" data-name="{{ service.name|e('html_attr') }}" data-billable="{{ service.billable ? '1' : '0' }}" + data-rate="{{ service.hourlyRate|default('') }}" data-note="{{ service.note|default('')|e('html_attr') }}">
@@ -134,6 +143,13 @@
+ +
+ + +
+
diff --git a/httpdocs/templates/timetracking/_entry_row.html.twig b/httpdocs/templates/timetracking/_entry_row.html.twig index 079bc7a..1d14434 100644 --- a/httpdocs/templates/timetracking/_entry_row.html.twig +++ b/httpdocs/templates/timetracking/_entry_row.html.twig @@ -25,6 +25,11 @@ {% include '_atoms/icon-lock.html.twig' %} {% else %} +