浏览代码

different money entries for customer, project and performance. stop-clock.

master
父节点
当前提交
1c6a36be29
共有 26 个文件被更改,包括 1491 次插入39 次删除
  1. +1
    -0
      httpdocs/assets/app.js
  2. +90
    -7
      httpdocs/assets/scripts/crud.js
  3. +10
    -4
      httpdocs/assets/scripts/entries.js
  4. +658
    -0
      httpdocs/assets/scripts/stopwatch.js
  5. +34
    -0
      httpdocs/assets/styles/components/_entry-form.scss
  6. +283
    -0
      httpdocs/assets/styles/components/_stopwatch.scss
  7. +1
    -0
      httpdocs/assets/styles/main.scss
  8. +12
    -10
      httpdocs/src/Command/SeedCommand.php
  9. +3
    -0
      httpdocs/src/Controller/ProjectController.php
  10. +8
    -5
      httpdocs/src/Controller/ServiceController.php
  11. +167
    -0
      httpdocs/src/Controller/TimeTrackingController.php
  12. +6
    -0
      httpdocs/src/Entity/Tenant/Project.php
  13. +6
    -0
      httpdocs/src/Entity/Tenant/Service.php
  14. +8
    -0
      httpdocs/src/Entity/Tenant/TimeEntry.php
  15. +17
    -2
      httpdocs/src/Repository/Tenant/TimeEntryRepository.php
  16. +3
    -2
      httpdocs/src/Service/ReportExportService.php
  17. +3
    -0
      httpdocs/src/Twig/AppExtension.php
  18. +8
    -0
      httpdocs/templates/_atoms/icon-stopwatch.html.twig
  19. +56
    -1
      httpdocs/templates/_sections/nav.html.twig
  20. +35
    -7
      httpdocs/templates/client/index.html.twig
  21. +42
    -0
      httpdocs/templates/project/index.html.twig
  22. +1
    -1
      httpdocs/templates/report/times.html.twig
  23. +16
    -0
      httpdocs/templates/service/index.html.twig
  24. +5
    -0
      httpdocs/templates/timetracking/_entry_row.html.twig
  25. +2
    -0
      httpdocs/templates/timetracking/week.html.twig
  26. +16
    -0
      httpdocs/translations/messages.de.yaml

+ 1
- 0
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';

+ 90
- 7
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 = `<span class="crud-row__meta">${c} ${c === 1 ? t('projectSingular') : t('projectPlural')}</span>`;
editFields = `
<label class="entry-form__label">${t('labelName')}</label>
<div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div>
<label class="entry-form__label">${t('labelRate')}</label>
<div class="entry-form__field" style="gap:8px">
<input type="number" class="input edit-rate" style="width:100px" value="${esc(data.hourlyRate ?? '')}" step="0.01" min="0" />
<span style="color:#7a8a9a;font-size:0.875rem">€</span>
<div class="entry-form__field">
<div class="rate-mode">
<label class="rate-mode__option">
<input type="radio" name="edit-rate-mode-${data.id}" value="default" ${!hasCustomRate ? 'checked' : ''} />
<span>${t('rateModeDefault')}</span>
</label>
<label class="rate-mode__option">
<input type="radio" name="edit-rate-mode-${data.id}" value="custom" ${hasCustomRate ? 'checked' : ''} />
<span>${t('rateModeCustom')}</span>
</label>
<div class="rate-mode__input"${!hasCustomRate ? ' hidden' : ''}>
<input type="number" class="input input--rate edit-rate" value="${esc(data.hourlyRate ?? '')}" step="0.01" min="0" />
<span class="entry-form__unit">€</span>
</div>
</div>
</div>
<label class="entry-form__label">${t('labelNote')}</label>
<div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`;
}

if (data.clientName !== undefined && data.projectCount === undefined) {
const hasCustomRate = data.hourlyRate != null && data.hourlyRate !== '';
metaHtml = `<span class="crud-row__meta">${esc(data.clientName)}</span>`;
editFields = `
<label class="entry-form__label">${t('labelName')}</label>
<div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div>
<label class="entry-form__label">${t('labelClient')}</label>
<div class="entry-form__field"><select class="select edit-client">${buildClientOptions(data.clientId)}</select></div>
<label class="entry-form__label">${t('labelRate')}</label>
<div class="entry-form__field">
<div class="rate-mode">
<label class="rate-mode__option">
<input type="radio" name="edit-rate-mode-${data.id}" value="default" ${!hasCustomRate ? 'checked' : ''} />
<span>${t('rateModeDefault')}</span>
</label>
<label class="rate-mode__option">
<input type="radio" name="edit-rate-mode-${data.id}" value="custom" ${hasCustomRate ? 'checked' : ''} />
<span>${t('rateModeCustom')}</span>
</label>
<div class="rate-mode__input"${!hasCustomRate ? ' hidden' : ''}>
<input type="number" class="input input--rate edit-rate" value="${esc(data.hourlyRate ?? '')}" step="0.01" min="0" />
<span class="entry-form__unit">€</span>
</div>
</div>
</div>
<label class="entry-form__label">${t('labelNote')}</label>
<div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`;
}
@@ -395,11 +472,16 @@ function buildRowHTML(data) {
<div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div>
<label class="entry-form__label">${t('labelBillable')}</label>
<div class="entry-form__field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<label class="crud-checkbox-label">
<input type="checkbox" class="edit-billable" ${data.billable ? 'checked' : ''} />
<span style="font-size:0.875rem">${t('billableLabel')}</span>
<span>${t('billableLabel')}</span>
</label>
</div>
<label class="entry-form__label">${t('labelRate')}</label>
<div class="entry-form__field entry-form__field--rate">
<input type="number" class="input input--rate edit-rate" value="${esc(data.hourlyRate ?? '')}" step="0.01" min="0" />
<span class="entry-form__unit">€</span>
</div>
<label class="entry-form__label">${t('labelNote')}</label>
<div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`;
}
@@ -448,4 +530,5 @@ document.addEventListener('DOMContentLoaded', () => {
initCreateForm();
initList();
initTabs();
initRateModeToggles();
});

+ 10
- 4
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) {
? `<span class="entry-row__badge">${esc(entry.durationFormatted)}</span>
<span class="entry-row__lock-indicator" title="${t('invoicedTitle')}">${LOCK_SVG}</span>`
: `<span class="entry-row__badge">${esc(entry.durationFormatted)}</span>
<button class="entry-row__btn entry-row__btn--stopwatch" title="${t('btnTimerToggle')}" data-action="timer-toggle">
${STOPWATCH_SVG}
</button>
<button class="entry-row__btn entry-row__btn--edit" title="${t('btnEdit')}" data-action="edit">
<svg viewBox="0 0 16 16" fill="none"><path d="M11 2l3 3L5 14H2v-3L11 2z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
@@ -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) {


+ 658
- 0
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 = `<svg viewBox="0 0 16 16" fill="none"><circle cx="8" cy="9" r="5.5" stroke="currentColor" stroke-width="1.3"/><path d="M8 6v3.5l2 1.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/><path d="M6.5 1.5h3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M8 1.5v2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M12.5 4.5l1-1" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`;

function buildEntryLabel(entry) {
if (!entry) return '';
let label = `${entry.projectName} (${entry.clientName})`;
if (entry.serviceName) label += ` – ${entry.serviceName}`;
return label;
}

async function apiCall(url, options = {}) {
const res = await fetch(url, options);
const ct = res.headers.get('content-type') || '';
if (!ct.includes('application/json')) {
console.error(`[Stopwatch] ${url} returned non-JSON:`, res.status, ct);
return { ok: false, status: res.status, data: null };
}
const data = await res.json();
return { ok: res.ok, status: res.status, data };
}

// ── Searchable Select ─────────────────────────────────────────────────────────

class SearchableSelect {
constructor(container) {
this.container = container;
this.value = '';
this.label = '';
this.groups = [];
this.open = false;
this.highlightIdx = -1;
this.placeholder = container.dataset.placeholder || '...';

this.container.innerHTML = `
<button type="button" class="ss__trigger">
<span class="ss__value">${esc(this.placeholder)}</span>
<svg class="ss__arrow" viewBox="0 0 10 6"><path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linecap="round"/></svg>
</button>
<div class="ss__dropdown" hidden>
<input type="text" class="ss__search" placeholder="${esc(t('search'))}">
<div class="ss__list"></div>
</div>`;

this.trigger = container.querySelector('.ss__trigger');
this.valueEl = container.querySelector('.ss__value');
this.dropdown = container.querySelector('.ss__dropdown');
this.search = container.querySelector('.ss__search');
this.list = container.querySelector('.ss__list');

this.trigger.addEventListener('click', (e) => {
e.stopPropagation();
this.toggle();
});
this.search.addEventListener('input', () => this.render());
this.search.addEventListener('keydown', (e) => this.onKeydown(e));
this.list.addEventListener('click', (e) => {
const item = e.target.closest('[data-value]');
if (item) this.select(item.dataset.value, item.dataset.label);
});
document.addEventListener('click', (e) => {
if (this.open && !this.container.contains(e.target)) this.close();
});
}

setGroups(groups) {
this.groups = groups;
}

setValue(val) {
for (const g of this.groups) {
for (const item of g.items) {
if (String(item.id) === String(val)) {
this.value = String(item.id);
this.label = item.name;
this.valueEl.textContent = item.name;
this.valueEl.classList.add('ss__value--selected');
return;
}
}
}
this.value = '';
this.label = '';
this.valueEl.textContent = this.placeholder;
this.valueEl.classList.remove('ss__value--selected');
}

getValue() { return this.value; }

toggle() {
this.open ? this.close() : this.openDropdown();
}

openDropdown() {
this.open = true;
this.dropdown.hidden = false;
this.search.value = '';
this.highlightIdx = -1;
this.render();
this.search.focus();
}

close() {
this.open = false;
this.dropdown.hidden = true;
}

select(val, label) {
this.value = val;
this.label = label;
this.valueEl.textContent = label;
this.valueEl.classList.add('ss__value--selected');
this.close();
}

focus() {
this.openDropdown();
}

render() {
const q = this.search.value.toLowerCase().trim();
let html = '';
let idx = 0;
const visibleItems = [];

for (const g of this.groups) {
const filtered = g.items.filter(item =>
!q || item.name.toLowerCase().includes(q) || g.label.toLowerCase().includes(q)
);
if (!filtered.length) continue;

html += `<div class="ss__group">${esc(g.label)}</div>`;
for (const item of filtered) {
const active = String(item.id) === this.value ? ' ss__item--active' : '';
const hl = idx === this.highlightIdx ? ' ss__item--highlight' : '';
html += `<div class="ss__item${active}${hl}" data-value="${item.id}" data-label="${esc(item.name)}" data-idx="${idx}">${esc(item.name)}</div>`;
visibleItems.push(item);
idx++;
}
}

this.list.innerHTML = html || `<div class="ss__empty">–</div>`;
this.visibleCount = visibleItems.length;
}

onKeydown(e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
this.highlightIdx = Math.min(this.highlightIdx + 1, this.visibleCount - 1);
this.render();
this.scrollToHighlight();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.highlightIdx = Math.max(this.highlightIdx - 1, 0);
this.render();
this.scrollToHighlight();
} else if (e.key === 'Enter') {
e.preventDefault();
const el = this.list.querySelector(`[data-idx="${this.highlightIdx}"]`);
if (el) this.select(el.dataset.value, el.dataset.label);
} else if (e.key === 'Escape') {
this.close();
}
}

scrollToHighlight() {
const el = this.list.querySelector('.ss__item--highlight');
if (el) el.scrollIntoView({ block: 'nearest' });
}
}

// ── StopwatchManager ──────────────────────────────────────────────────────────

class StopwatchManager {
constructor() {
this.toggle = document.getElementById('stopwatch-toggle');
this.popover = document.getElementById('stopwatch-popover');
this.display = document.getElementById('stopwatch-display');
this.startBtn = document.getElementById('stopwatch-start');
this.noteField = document.getElementById('stopwatch-note');
this.hamburgerBtn = document.getElementById('hamburger-stopwatch');
this.headerTime = document.getElementById('stopwatch-header-time');

this.projectSelect = null;
this.serviceSelect = null;
const projEl = document.getElementById('stopwatch-project');
const svcEl = document.getElementById('stopwatch-service');
if (projEl) this.projectSelect = new SearchableSelect(projEl);
if (svcEl) this.serviceSelect = new SearchableSelect(svcEl);

this.running = false;
this.entryId = null;
this.startedAt = null;
this.baseDuration = 0;
this.entryLabel = '';
this.tickInterval = null;
this.originalTitle = document.title;
this.cachedOptions = null;
this.busy = false;

if (!this.toggle) return;
this.init();
}

async init() {
this.toggle.addEventListener('click', (e) => {
e.stopPropagation();
this.handleToggleClick();
});

this.startBtn?.addEventListener('click', () => this.startNew());

document.addEventListener('click', (e) => {
if (this.popover && !this.popover.hidden
&& !this.popover.contains(e.target)
&& !this.toggle.contains(e.target)) {
this.closePopover();
}
});

this.hamburgerBtn?.addEventListener('click', () => this.handleToggleClick());

this.loadFromLocalStorage();
await this.loadStatus();
}

// ── Toggle ──────────────────────────────────────────────────────────────────

handleToggleClick() {
if (this.running) {
this.stop();
} else {
this.togglePopover();
}
}

async togglePopover() {
if (!this.popover) return;

if (!this.popover.hidden) {
this.closePopover();
return;
}

await this.loadOptions();
this.populateSelects();
this.popover.hidden = false;
this.toggle.setAttribute('aria-expanded', 'true');
this.projectSelect?.focus();
}

closePopover() {
if (!this.popover) return;
this.popover.hidden = true;
this.toggle.setAttribute('aria-expanded', 'false');
this.projectSelect?.close();
this.serviceSelect?.close();
}

// ── Select-Builder ──────────────────────────────────────────────────────────

populateSelects() {
if (!this.cachedOptions) return;

const lastProject = localStorage.getItem(LAST_PROJECT_KEY);
const lastService = localStorage.getItem(LAST_SERVICE_KEY);

if (this.projectSelect) {
const groups = {};
(this.cachedOptions.projects ?? []).forEach(p => {
if (!groups[p.clientName]) groups[p.clientName] = [];
groups[p.clientName].push(p);
});
this.projectSelect.setGroups(
Object.entries(groups).map(([label, items]) => ({ label, items }))
);
if (lastProject) this.projectSelect.setValue(lastProject);
}

if (this.serviceSelect) {
const billable = (this.cachedOptions.services ?? []).filter(s => s.billable);
const notBillable = (this.cachedOptions.services ?? []).filter(s => !s.billable);
const groups = [];
if (billable.length) groups.push({ label: t('billable'), items: billable });
if (notBillable.length) groups.push({ label: t('notBillable'), items: notBillable });
this.serviceSelect.setGroups(groups);
if (lastService) this.serviceSelect.setValue(lastService);
}
}

// ── API ─────────────────────────────────────────────────────────────────────

async loadStatus() {
try {
const { ok, data } = await apiCall('/api/timer/status');
if (!ok || !data) {
if (this.running) {
this.running = false;
this.clearLocalStorage();
this.applyStoppedState();
}
return;
}

if (data.running && data.startedAt) {
this.running = true;
this.entryId = data.entry?.id ?? null;
this.startedAt = new Date(data.startedAt).getTime();
this.baseDuration = data.entry?.duration ?? 0;
this.entryLabel = buildEntryLabel(data.entry);
this.saveToLocalStorage();
this.applyRunningState();
} else if (this.running) {
this.running = false;
this.clearLocalStorage();
this.applyStoppedState();
}
} catch {
if (this.running) {
this.running = false;
this.clearLocalStorage();
this.applyStoppedState();
}
}
}

async loadOptions() {
if (this.cachedOptions) return;
try {
const { ok, data } = await apiCall('/api/timer/options');
if (ok && data) this.cachedOptions = data;
} catch { /* silent */ }
}

async startNew() {
if (this.busy) return;

const projectId = this.projectSelect?.getValue();
if (!projectId) {
this.projectSelect?.focus();
return;
}

this.busy = true;
this.startBtn.disabled = true;

try {
const serviceId = this.serviceSelect?.getValue();

const { ok, status, data } = await apiCall('/api/timer/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectId: parseInt(projectId, 10),
serviceId: serviceId ? parseInt(serviceId, 10) : null,
note: this.noteField?.value || null,
}),
});

if (status === 409 && data) {
const name = data.runningEntry
? `${data.runningEntry.clientName} / ${data.runningEntry.projectName}`
: '';
const msg = t('confirmReplace').replace('%project%', name);
if (!confirm(msg)) return;

await this.forceStop();
this.busy = false;
return this.startNew();
}

if (!ok) {
alert(t('errorStart') + (data?.error ? `\n${data.error}` : ` (HTTP ${status})`));
return;
}

this.running = true;
this.entryId = data.entry.id;
this.startedAt = new Date(data.entry.timerStartedAt).getTime();
this.baseDuration = data.entry.duration;
this.entryLabel = buildEntryLabel(data.entry);

this.saveToLocalStorage();
this.applyRunningState();
this.closePopover();

if (projectId) localStorage.setItem(LAST_PROJECT_KEY, projectId);
if (serviceId) localStorage.setItem(LAST_SERVICE_KEY, serviceId);

try {
this.addEntryToDOM(data.entry, data.totalDuration);
} catch (domEx) {
console.error('[Stopwatch] addEntryToDOM error (non-fatal):', domEx);
}
} catch (ex) {
console.error('[Stopwatch] startNew error:', ex);
alert(t('errorStart') + `\n${ex.message}`);
} finally {
this.busy = false;
if (this.startBtn) this.startBtn.disabled = false;
}
}

async resumeEntry(entryId) {
if (this.busy) return;
if (this.running && this.entryId === entryId) {
this.stop();
return;
}

this.busy = true;

try {
const { ok, status, data } = await apiCall(`/api/timer/start/${entryId}`, {
method: 'POST',
});

if (status === 409 && data) {
const name = data.runningEntry
? `${data.runningEntry.clientName} / ${data.runningEntry.projectName}`
: '';
const msg = t('confirmReplace').replace('%project%', name);
if (!confirm(msg)) return;

await this.forceStop();
this.busy = false;
return this.resumeEntry(entryId);
}

if (!ok) {
alert(t('errorStart') + (data?.error ? `\n${data.error}` : ` (HTTP ${status})`));
return;
}

this.running = true;
this.entryId = data.entry.id;
this.startedAt = new Date(data.entry.timerStartedAt).getTime();
this.baseDuration = data.entry.duration;
this.entryLabel = buildEntryLabel(data.entry);

this.saveToLocalStorage();
this.applyRunningState();
} catch (ex) {
console.error('[Stopwatch] resumeEntry error:', ex);
alert(t('errorStart') + `\n${ex.message}`);
} finally {
this.busy = false;
}
}

async stop() {
if (this.busy) return;
this.busy = true;

try {
const { ok, status, data } = await apiCall('/api/timer/stop', { method: 'POST' });

if (!ok) {
alert(t('errorStop') + (data?.error ? `\n${data.error}` : ` (HTTP ${status})`));
if (status === 404) {
this.running = false;
this.clearLocalStorage();
this.applyStoppedState();
}
return;
}

const stoppedEntryId = this.entryId;
this.running = false;
this.clearLocalStorage();
this.applyStoppedState();

try {
this.updateEntryInDOM(stoppedEntryId, data.entry, data.totalDuration);
} catch (domEx) {
console.error('[Stopwatch] updateEntryInDOM error (non-fatal):', domEx);
}
} catch (ex) {
console.error('[Stopwatch] stop error:', ex);
alert(t('errorStop') + `\n${ex.message}`);
} finally {
this.busy = false;
}
}

async forceStop() {
try {
const stoppedId = this.entryId;
const { ok, data } = await apiCall('/api/timer/stop', { method: 'POST' });
if (!ok) return;
this.running = false;
this.clearLocalStorage();
this.applyStoppedState();
try {
this.updateEntryInDOM(stoppedId, data.entry, data.totalDuration);
} catch { /* silent */ }
} catch { /* silent */ }
}

// ── Timer-Tick ──────────────────────────────────────────────────────────────

startTicking() {
if (this.tickInterval) return;
this.tick();
this.tickInterval = setInterval(() => this.tick(), 1000);
}

stopTicking() {
if (this.tickInterval) {
clearInterval(this.tickInterval);
this.tickInterval = null;
}
document.title = this.originalTitle;
if (this.display) this.display.textContent = '0:00';
if (this.headerTime) {
this.headerTime.textContent = '';
this.headerTime.hidden = true;
}
}

tick() {
if (!this.startedAt) return;
const elapsedSec = Math.floor((Date.now() - this.startedAt) / 1000);
const totalSec = (this.baseDuration * 60) + elapsedSec;

const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;

const long = h > 0
? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
: `${m}:${String(s).padStart(2, '0')}`;

const short = `${h}:${String(m).padStart(2, '0')}`;

document.title = `${short} - ${this.originalTitle}`;
if (this.display) this.display.textContent = long;

if (this.headerTime) {
this.headerTime.textContent = short;
this.headerTime.hidden = false;
}

if (this.entryId) {
const badge = document.querySelector(`#entry-${this.entryId} .entry-row__badge`);
if (badge) badge.textContent = short;
}
}

// ── Visual State ────────────────────────────────────────────────────────────

applyRunningState() {
this.toggle?.classList.add('main-nav__stopwatch--running');
this.hamburgerBtn?.classList.add('hamburger-nav__stopwatch--running');
if (this.toggle && this.entryLabel) this.toggle.title = this.entryLabel;
this.startTicking();
this.markActiveEntryRow();
}

applyStoppedState() {
this.toggle?.classList.remove('main-nav__stopwatch--running');
this.hamburgerBtn?.classList.remove('hamburger-nav__stopwatch--running');
if (this.toggle) this.toggle.title = t('title');
this.stopTicking();
this.entryId = null;
this.startedAt = null;
this.baseDuration = 0;
this.entryLabel = '';
this.clearActiveEntryRows();
}

markActiveEntryRow() {
this.clearActiveEntryRows();
if (!this.entryId) return;
const row = document.getElementById(`entry-${this.entryId}`);
if (row) row.classList.add('entry-row--timer-active');
}

clearActiveEntryRows() {
document.querySelectorAll('.entry-row--timer-active')
.forEach(el => el.classList.remove('entry-row--timer-active'));
}

// ── DOM Integration ─────────────────────────────────────────────────────────

addEntryToDOM(entry, totalDuration) {
if (!window.entryManager) return;
window.entryManager.addEntryToDOM(entry);
if (totalDuration) window.entryManager.updateTotal(totalDuration);
this.markActiveEntryRow();
}

updateEntryInDOM(entryId, entry, totalDuration) {
if (!window.entryManager || !entryId) return;
const row = document.getElementById(`entry-${entryId}`);
if (row) {
window.entryManager.updateRowDisplay(row, entry);
row.dataset.duration = entry.duration;
}
if (totalDuration) window.entryManager.updateTotal(totalDuration);
}

// ── LocalStorage ────────────────────────────────────────────────────────────

saveToLocalStorage() {
try {
localStorage.setItem(LS_KEY, JSON.stringify({
entryId: this.entryId,
startedAt: this.startedAt,
baseDuration: this.baseDuration,
entryLabel: this.entryLabel,
}));
} catch { /* silent */ }
}

loadFromLocalStorage() {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return;
const data = JSON.parse(raw);
if (data?.startedAt) {
this.running = true;
this.entryId = data.entryId;
this.startedAt = data.startedAt;
this.baseDuration = data.baseDuration ?? 0;
this.entryLabel = data.entryLabel ?? '';
this.applyRunningState();
}
} catch { /* silent */ }
}

clearLocalStorage() {
try { localStorage.removeItem(LS_KEY); } catch { /* silent */ }
}

// ── Public ─────────────────────────────────────────────────────────────────

static get SVG() { return STOPWATCH_SVG; }

isRunningForEntry(entryId) {
return this.running && this.entryId === entryId;
}
}

window.stopwatchManager = null;
document.addEventListener('DOMContentLoaded', () => {
window.stopwatchManager = new StopwatchManager();
});

export { STOPWATCH_SVG };

+ 34
- 0
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;
}

+ 283
- 0
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;
}
}

+ 1
- 0
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';


+ 12
- 10
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);
}



+ 3
- 0
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(),
];


+ 8
- 5
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(),
];
}
}

+ 167
- 0
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')]


+ 6
- 0
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; }



+ 6
- 0
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; }



+ 8
- 0
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'),
];
}
}

+ 17
- 2
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()


+ 3
- 2
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;



+ 3
- 0
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() ?? ''; }


+ 8
- 0
httpdocs/templates/_atoms/icon-stopwatch.html.twig 查看文件

@@ -0,0 +1,8 @@
{# templates/_atoms/icon-stopwatch.html.twig #}
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="9" r="5.5" stroke="currentColor" stroke-width="1.3"/>
<path d="M8 6v3.5l2 1.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.5 1.5h3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M8 1.5v2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M12.5 4.5l1-1" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>

+ 56
- 1
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 }}
</a>
{% if app.user %}
<div class="main-nav__stopwatch-wrap">
<button class="main-nav__stopwatch" id="stopwatch-toggle"
title="{{ 'app.stopwatch.title'|trans }}"
aria-expanded="false">
{% include '_atoms/icon-stopwatch.html.twig' %}
<span class="main-nav__stopwatch-time" id="stopwatch-header-time" hidden></span>
</button>
<div class="stopwatch-popover" id="stopwatch-popover" hidden>
<div class="stopwatch-popover__timer" id="stopwatch-display">0:00</div>
<div class="stopwatch-popover__form">
<div id="stopwatch-project" class="searchable-select" data-placeholder="{{ 'app.stopwatch.select_project'|trans }}"></div>
<div id="stopwatch-service" class="searchable-select" data-placeholder="{{ 'app.stopwatch.select_service'|trans }}"></div>
<textarea id="stopwatch-note" class="textarea" rows="2"
placeholder="{{ 'app.entry.placeholder_note'|trans }}"></textarea>
<div class="stopwatch-popover__actions">
<button type="button" class="btn btn-primary" id="stopwatch-start">
{{ 'app.stopwatch.btn_start'|trans }}
</button>
</div>
</div>
</div>
</div>
{% endif %}
<a href="{{ path('report_times') }}"
class="main-nav__item{% if currentRoute starts with 'report' %} main-nav__item--active{% 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 }}
</a>
{% if app.user %}
<button class="hamburger-nav__stopwatch" id="hamburger-stopwatch">
{% include '_atoms/icon-stopwatch.html.twig' %}
<span>{{ 'app.stopwatch.title'|trans }}</span>
</button>
{% endif %}
<a href="{{ path('report_times') }}"
class="hamburger-nav__item{% if currentRoute starts with 'report' %} hamburger-nav__item--active{% endif %}">
{{ 'app.nav.reports'|trans }}
@@ -86,4 +116,29 @@
{{ 'app.nav.logout'|trans }}
</a>
</div>
</div>
</div>

{% if app.user %}
<script>
window.STOPWATCH = {
trackingInterval: {{ trackingInterval() }},
i18n: {
title: {{ 'app.stopwatch.title'|trans|json_encode|raw }},
btnStart: {{ 'app.stopwatch.btn_start'|trans|json_encode|raw }},
btnStop: {{ 'app.stopwatch.btn_stop'|trans|json_encode|raw }},
resume: {{ 'app.stopwatch.resume'|trans|json_encode|raw }},
confirmReplace: {{ 'app.stopwatch.confirm_replace'|trans|json_encode|raw }},
confirmStop: {{ 'app.stopwatch.confirm_stop'|trans|json_encode|raw }},
errorStart: {{ 'app.stopwatch.error_start'|trans|json_encode|raw }},
errorStop: {{ 'app.stopwatch.error_stop'|trans|json_encode|raw }},
selectPh: {{ 'app.entry.select_placeholder'|trans|json_encode|raw }},
billable: {{ 'app.service.billable'|trans|json_encode|raw }},
notBillable: {{ 'app.service.not_billable'|trans|json_encode|raw }},
placeholderNote:{{ 'app.entry.placeholder_note'|trans|json_encode|raw }},
selectProject: {{ 'app.stopwatch.select_project'|trans|json_encode|raw }},
selectService: {{ 'app.stopwatch.select_service'|trans|json_encode|raw }},
search: {{ 'app.stopwatch.search'|trans|json_encode|raw }},
},
};
</script>
{% endif %}

+ 35
- 7
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 }},
},
};
</script>
@@ -53,9 +55,21 @@ window.CRUD = {
</div>

<label class="entry-form__label">{{ 'app.crud.label_rate'|trans }}</label>
<div class="entry-form__field entry-form__field--rate">
<input type="number" id="create-rate" class="input input--rate" placeholder="0,00" step="0.01" min="0" />
<span class="entry-form__unit">&euro;</span>
<div class="entry-form__field">
<div class="rate-mode">
<label class="rate-mode__option">
<input type="radio" name="create-rate-mode" value="default" checked />
<span>{{ 'app.crud.rate_mode_default'|trans }}</span>
</label>
<label class="rate-mode__option">
<input type="radio" name="create-rate-mode" value="custom" />
<span>{{ 'app.crud.rate_mode_custom'|trans }}</span>
</label>
<div class="rate-mode__input" hidden>
<input type="number" id="create-rate" class="input input--rate" placeholder="0,00" step="0.01" min="0" />
<span class="entry-form__unit">&euro;</span>
</div>
</div>
</div>

<label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label>
@@ -120,10 +134,24 @@ window.CRUD = {
</div>

<label class="entry-form__label">{{ 'app.crud.label_rate'|trans }}</label>
<div class="entry-form__field entry-form__field--rate">
<input type="number" class="input input--rate edit-rate"
value="{{ client.hourlyRate|default('') }}" step="0.01" min="0" />
<span class="entry-form__unit">&euro;</span>
<div class="entry-form__field">
<div class="rate-mode">
<label class="rate-mode__option">
<input type="radio" name="edit-rate-mode-{{ client.id }}" value="default"
{{ client.hourlyRate is null ? 'checked' : '' }} />
<span>{{ 'app.crud.rate_mode_default'|trans }}</span>
</label>
<label class="rate-mode__option">
<input type="radio" name="edit-rate-mode-{{ client.id }}" value="custom"
{{ client.hourlyRate is not null ? 'checked' : '' }} />
<span>{{ 'app.crud.rate_mode_custom'|trans }}</span>
</label>
<div class="rate-mode__input"{{ client.hourlyRate is null ? ' hidden' : '' }}>
<input type="number" class="input input--rate edit-rate"
value="{{ client.hourlyRate|default('') }}" step="0.01" min="0" />
<span class="entry-form__unit">&euro;</span>
</div>
</div>
</div>

<label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label>


+ 42
- 0
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 }},
},
};
</script>
@@ -61,6 +63,24 @@ window.CRUD = {
</select>
</div>

<label class="entry-form__label">{{ 'app.crud.label_rate'|trans }}</label>
<div class="entry-form__field">
<div class="rate-mode">
<label class="rate-mode__option">
<input type="radio" name="create-rate-mode" value="default" checked />
<span>{{ 'app.crud.rate_mode_default'|trans }}</span>
</label>
<label class="rate-mode__option">
<input type="radio" name="create-rate-mode" value="custom" />
<span>{{ 'app.crud.rate_mode_custom'|trans }}</span>
</label>
<div class="rate-mode__input" hidden>
<input type="number" id="create-rate" class="input input--rate" placeholder="0,00" step="0.01" min="0" />
<span class="entry-form__unit">&euro;</span>
</div>
</div>
</div>

<label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label>
<div class="entry-form__field">
<textarea id="create-note" class="textarea" rows="2"></textarea>
@@ -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') }}">

<div class="crud-row__display">
@@ -132,6 +153,27 @@ window.CRUD = {
</select>
</div>

<label class="entry-form__label">{{ 'app.crud.label_rate'|trans }}</label>
<div class="entry-form__field">
<div class="rate-mode">
<label class="rate-mode__option">
<input type="radio" name="edit-rate-mode-{{ project.id }}" value="default"
{{ project.hourlyRate is null ? 'checked' : '' }} />
<span>{{ 'app.crud.rate_mode_default'|trans }}</span>
</label>
<label class="rate-mode__option">
<input type="radio" name="edit-rate-mode-{{ project.id }}" value="custom"
{{ project.hourlyRate is not null ? 'checked' : '' }} />
<span>{{ 'app.crud.rate_mode_custom'|trans }}</span>
</label>
<div class="rate-mode__input"{{ project.hourlyRate is null ? ' hidden' : '' }}>
<input type="number" class="input input--rate edit-rate"
value="{{ project.hourlyRate|default('') }}" step="0.01" min="0" />
<span class="entry-form__unit">&euro;</span>
</div>
</div>
</div>

<label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label>
<div class="entry-form__field">
<textarea class="textarea edit-note" rows="2">{{ project.note|default('') }}</textarea>


+ 1
- 1
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) %}



+ 16
- 0
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 }},
},
};
</script>
@@ -58,6 +60,12 @@
</label>
</div>

<label class="entry-form__label">{{ 'app.crud.label_rate'|trans }}</label>
<div class="entry-form__field entry-form__field--rate">
<input type="number" id="create-rate" class="input input--rate" placeholder="0,00" step="0.01" min="0" />
<span class="entry-form__unit">&euro;</span>
</div>

<label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label>
<div class="entry-form__field">
<textarea id="create-note" class="textarea" rows="2"></textarea>
@@ -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') }}">

<div class="crud-row__display">
@@ -134,6 +143,13 @@
</label>
</div>

<label class="entry-form__label">{{ 'app.crud.label_rate'|trans }}</label>
<div class="entry-form__field entry-form__field--rate">
<input type="number" class="input input--rate edit-rate"
value="{{ service.hourlyRate|default('') }}" step="0.01" min="0" />
<span class="entry-form__unit">&euro;</span>
</div>

<label class="entry-form__label">{{ 'app.crud.label_note'|trans }}</label>
<div class="entry-form__field">
<textarea class="textarea edit-note" rows="2">{{ service.note|default('') }}</textarea>


+ 5
- 0
httpdocs/templates/timetracking/_entry_row.html.twig 查看文件

@@ -25,6 +25,11 @@
{% include '_atoms/icon-lock.html.twig' %}
</span>
{% else %}
<button class="entry-row__btn entry-row__btn--stopwatch"
title="{{ 'app.stopwatch.resume'|trans }}"
data-action="timer-toggle">
{% include '_atoms/icon-stopwatch.html.twig' %}
</button>
<button class="entry-row__btn entry-row__btn--edit"
title="{{ 'app.entry.btn_edit'|trans }}"
data-action="edit">


+ 2
- 0
httpdocs/templates/timetracking/week.html.twig 查看文件

@@ -66,6 +66,8 @@ window.TT = {
invoicedTitle: {{ 'app.entry.invoiced_title'|trans|json_encode|raw }},
noteShow: {{ 'app.entry.note_show'|trans|json_encode|raw }},
noteHide: {{ 'app.entry.note_hide'|trans|json_encode|raw }},
btnTimerToggle: {{ 'app.stopwatch.resume'|trans|json_encode|raw }},
confirmTimerReplace: {{ 'app.stopwatch.confirm_replace'|trans|json_encode|raw }},
},
};
</script>


+ 16
- 0
httpdocs/translations/messages.de.yaml 查看文件

@@ -110,6 +110,8 @@ app:
label_note: "Bemerkung"
label_rate: "Stundensatz"
label_client: "Kunde"
rate_mode_default: "Standard-Sätze der Leistungen"
rate_mode_custom: "Eigener Stundensatz"
tab_active: "Aktiv"
tab_archived: "Archiviert"
btn_restore: "Wiederherstellen"
@@ -412,6 +414,20 @@ app:
confirm_invalid: "Ungültiger Bestätigungslink."
confirm_expired: "Dieser Link ist abgelaufen (gültig 24 Stunden). Bitte registriere dich erneut."

stopwatch:
title: "Stoppuhr"
btn_start: "Stoppuhr starten"
btn_stop: "Stoppuhr stoppen"
resume: "Timer fortsetzen"
confirm_replace: "Es läuft bereits ein Timer für \"%project%\". Stoppen und neuen starten?"
confirm_stop: "Timer stoppen?"
error_start: "Fehler beim Starten des Timers."
error_stop: "Fehler beim Stoppen des Timers."
stopped: "Timer gestoppt"
select_project: "Projekt wählen…"
select_service: "Leistung wählen…"
search: "Suchen…"

home:
title: "spawntree Timetracker"
btn_start: "Kostenlos starten"


正在加载...
取消
保存