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) {
-
+
${t('labelRate')}
+
+
+ €
+
${t('labelNote')}
`;
}
@@ -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 = {