+ value="${esc(entry.durationFormatted)}" autocomplete="off" />
@@ -107,12 +107,12 @@ function buildEntryRowHTML(entry, animate = false) {
data-duration="${entry.duration}"
data-project-id="${entry.projectId}"
data-service-id="${entry.serviceId ?? ''}"
- data-note="${(entry.note ?? '').replace(/"/g, '"')}"
+ data-note="${esc(entry.note ?? '')}"
data-invoiced="${invoiced ? 'true' : 'false'}">
-
${entry.clientName} / ${entry.projectName}${servicePart}
+
${esc(entry.clientName)} / ${esc(entry.projectName)}${servicePart}
${notePart}
@@ -123,6 +123,23 @@ function buildEntryRowHTML(entry, animate = false) {
`;
}
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+function getDailyTotalMinutes() {
+ let total = 0;
+ document.querySelectorAll('#entry-items .entry-row').forEach(row => {
+ total += parseInt(row.dataset.duration, 10) || 0;
+ });
+ return total;
+}
+
+function saveLastProject(id) { if (id) localStorage.setItem(LAST_PROJECT_KEY, id); }
+function getLastProject() { return localStorage.getItem(LAST_PROJECT_KEY); }
+function saveLastService(id) { if (id) localStorage.setItem(LAST_SERVICE_KEY, id); }
+function getLastService() { return localStorage.getItem(LAST_SERVICE_KEY); }
+
+// ── EntryManager ─────────────────────────────────────────────────────────────
+
class EntryManager {
constructor() {
this.list = document.getElementById('entry-list');
@@ -133,13 +150,8 @@ class EntryManager {
const cp = document.getElementById('create-project');
const cs = document.getElementById('create-service');
- document.getElementById('create-service')?.addEventListener('change', e => {
- saveLastService(e.target.value);
- });
-
- document.getElementById('create-project')?.addEventListener('change', e => {
- saveLastProject(e.target.value);
- });
+ cs?.addEventListener('change', e => saveLastService(e.target.value));
+ cp?.addEventListener('change', e => saveLastProject(e.target.value));
if (cp) {
const lastProject = getLastProject();
@@ -182,13 +194,13 @@ class EntryManager {
return;
}
- // Klick auf Anzeige-Bereich (kein Button) → Edit öffnen
if (e.target.closest('.entry-row__display') && row.dataset.invoiced !== 'true') {
this.openEdit(row);
}
}
async createEntry() {
+ const btn = document.getElementById('btn-create');
const durationRaw = document.getElementById('create-duration')?.value ?? '0:00';
const projectId = document.getElementById('create-project')?.value;
const serviceId = document.getElementById('create-service')?.value;
@@ -196,36 +208,27 @@ class EntryManager {
if (!projectId) { alert(t('errorNoProject')); return; }
- const duration = formatMinutes(roundToQuarter(parseDuration(durationRaw)));
-
- if (duration === '0:00') {
- alert(t('errorZeroDuration'));
- return;
- }
-
- const rawMinutes = roundToQuarter(parseDuration(durationRaw));
- const validation = validateDuration(rawMinutes);
- if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; }
- if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return;
-
- if (getDailyTotalMinutes() + rawMinutes > 1440) { alert(t('errorDailyLimitExceeded')); return; }
+ const dur = parseAndValidate(durationRaw);
+ if (dur.error) { alert(t(dur.error)); return; }
+ if (dur.warn && !confirm(t(dur.warn))) return;
+ if (getDailyTotalMinutes() + dur.minutes > MINUTES_PER_DAY) { alert(t('errorDailyLimitExceeded')); return; }
+ if (btn) btn.disabled = true;
try {
const res = await fetch('/api/entries', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
date: window.TT.activeDate,
- duration,
- projectId: parseInt(projectId),
- serviceId: serviceId ? parseInt(serviceId) : null,
+ duration: dur.formatted,
+ projectId: parseInt(projectId, 10),
+ serviceId: serviceId ? parseInt(serviceId, 10) : null,
note: note || null,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
- console.error('API Fehler:', res.status, err);
alert(t('errorSave') + (err.error ? `\n${err.error}` : ''));
return;
}
@@ -234,10 +237,10 @@ class EntryManager {
this.addEntryToDOM(data.entry);
this.updateTotal(data.totalDuration);
this.resetCreateForm();
-
- } catch (err) {
- console.error('Netzwerkfehler:', err);
+ } catch {
alert(t('errorSave'));
+ } finally {
+ if (btn) btn.disabled = false;
}
}
@@ -255,9 +258,7 @@ class EntryManager {
items.insertAdjacentHTML('beforeend', buildEntryRowHTML(entry, true));
const el = document.getElementById(`entry-${entry.id}`);
- requestAnimationFrame(() => requestAnimationFrame(() => {
- el?.classList.remove('entry-row--new');
- }));
+ if (el) animateIn(el, 'entry-row--new');
}
resetCreateForm() {
@@ -272,9 +273,7 @@ class EntryManager {
}
openEdit(row) {
- // Safety-Guard: invoiced-Einträge können nicht geöffnet werden
if (row.dataset.invoiced === 'true') return;
- // Kein Edit-Formular vorhanden → nicht öffnen
const editSection = row.querySelector('.entry-row__edit');
if (!editSection) return;
@@ -303,6 +302,9 @@ class EntryManager {
}
async saveEdit(row) {
+ const saveBtn = row.querySelector('[data-action="save"]');
+ if (saveBtn?.disabled) return;
+
const id = row.dataset.id;
const durationRaw = row.querySelector('.edit-duration')?.value ?? '0:00';
const projectId = row.querySelector('.edit-project')?.value;
@@ -311,35 +313,30 @@ class EntryManager {
if (!projectId) { alert(t('errorNoProject')); return; }
- const duration = formatMinutes(roundToQuarter(parseDuration(durationRaw)));
+ const dur = parseAndValidate(durationRaw);
+ if (dur.error) { alert(t(dur.error)); return; }
+ if (dur.warn && !confirm(t(dur.warn))) return;
- if (duration === '0:00') {
- alert(t('errorZeroDuration'));
+ const currentMinutes = parseInt(row.dataset.duration, 10) || 0;
+ if (getDailyTotalMinutes() - currentMinutes + dur.minutes > MINUTES_PER_DAY) {
+ alert(t('errorDailyLimitExceeded'));
return;
}
- const rawMinutes = roundToQuarter(parseDuration(durationRaw));
- const validation = validateDuration(rawMinutes);
- if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; }
- if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return;
-
- const currentEntryMinutes = parseInt(row.dataset.duration) || 0;
- if (getDailyTotalMinutes() - currentEntryMinutes + rawMinutes > 1440) { alert(t('errorDailyLimitExceeded')); return; }
-
+ if (saveBtn) saveBtn.disabled = true;
try {
const res = await fetch(`/api/entries/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
- duration,
- projectId: parseInt(projectId),
- serviceId: serviceId ? parseInt(serviceId) : null,
+ duration: dur.formatted,
+ projectId: parseInt(projectId, 10),
+ serviceId: serviceId ? parseInt(serviceId, 10) : null,
note: note || null,
}),
});
if (!res.ok) {
- console.error('PATCH fehlgeschlagen:', res.status);
alert(t('errorSave'));
return;
}
@@ -348,10 +345,10 @@ class EntryManager {
this.updateRowDisplay(row, data.entry);
this.updateTotal(data.totalDuration);
this.closeEdit(row);
-
- } catch (err) {
- console.error('saveEdit Fehler:', err);
+ } catch {
alert(t('errorSave'));
+ } finally {
+ if (saveBtn) saveBtn.disabled = false;
}
}
@@ -388,14 +385,14 @@ class EntryManager {
if (!res.ok) { alert(t('errorDelete')); return; }
const data = await res.json();
- row.classList.add('entry-row--removing');
+ removeWithAnimation(row, 'entry-row--removing');
setTimeout(() => {
- row.remove();
this.updateTotal(data.totalDuration);
this.checkIfEmpty();
- }, 280);
-
- } catch { alert(t('errorDelete')); }
+ }, ANIMATION_MS);
+ } catch {
+ alert(t('errorDelete'));
+ }
}
async loadEntriesForDate(dateStr) {
@@ -403,9 +400,9 @@ class EntryManager {
try {
this.list.classList.add('entry-list--fading');
- await new Promise(r => setTimeout(r, 180));
+ await new Promise(r => setTimeout(r, FADE_MS));
- const res = await fetch(`/api/entries?date=${dateStr}`);
+ const res = await fetch(`/api/entries?date=${dateStr}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
@@ -428,7 +425,7 @@ class EntryManager {
let html = '
';
entries.forEach(e => { html += buildEntryRowHTML(e, false); });
html += `
`;
+
${esc(totalDuration)} `;
this.list.innerHTML = html;
this.emptyState = null;
@@ -449,7 +446,7 @@ class EntryManager {
footer.id = 'entry-footer';
this.list.appendChild(footer);
}
- footer.innerHTML = `
${totalDuration}`;
+ footer.innerHTML = `
${esc(totalDuration)}`;
}
hideEmptyState() { this.emptyState?.remove(); this.emptyState = null; }
@@ -466,33 +463,7 @@ class EntryManager {
}
}
-function getDailyTotalMinutes() {
- let total = 0;
- document.querySelectorAll('#entry-items .entry-row').forEach(row => {
- total += parseInt(row.dataset.duration) || 0;
- });
- return total;
-}
-
-function saveLastProject(projectId) {
- if (projectId) localStorage.setItem(LAST_PROJECT_KEY, projectId);
-}
-
-function getLastProject() {
- return localStorage.getItem(LAST_PROJECT_KEY);
-}
-
-function saveLastService(serviceId) {
- if (serviceId) localStorage.setItem(LAST_SERVICE_KEY, serviceId);
-}
-
-function getLastService() {
- return localStorage.getItem(LAST_SERVICE_KEY);
-}
-
-// ── Minimal-Modus-Initialisierung ─────────────────────────────────────────────
-
-const NOTE_KEY = 'tt_minimal_note_open';
+// ── Minimal-Modus ────────────────────────────────────────────────────────────
function initMinimalMode() {
if (document.body.dataset.theme !== 'minimal') return;
@@ -527,9 +498,9 @@ function initWeekToggle() {
}
function initNoteToggle() {
- const btn = document.getElementById('btn-note-toggle');
- const label = document.querySelector('.entry-form__label--note');
- const field = document.querySelector('.entry-form__field--note');
+ const btn = document.getElementById('btn-note-toggle');
+ const label = document.querySelector('.entry-form__label--note');
+ const field = document.querySelector('.entry-form__field--note');
if (!btn) return;
const open = localStorage.getItem(NOTE_KEY) === '1';
@@ -539,7 +510,7 @@ function initNoteToggle() {
const nowOpen = label?.classList.toggle('is-visible');
field?.classList.toggle('is-visible');
btn.classList.toggle('is-open', !!nowOpen);
- btn.textContent = nowOpen ? '× Bemerkung ausblenden' : '+ Bemerkung hinzufügen';
+ btn.textContent = nowOpen ? t('noteHide') : t('noteShow');
localStorage.setItem(NOTE_KEY, nowOpen ? '1' : '0');
});
}
@@ -549,18 +520,17 @@ function setNoteVisible(open, btn, label, field) {
label?.classList.add('is-visible');
field?.classList.add('is-visible');
btn.classList.add('is-open');
- btn.textContent = '× Bemerkung ausblenden';
+ btn.textContent = t('noteHide');
} else {
- btn.textContent = '+ Bemerkung hinzufügen';
+ btn.textContent = t('noteShow');
}
}
function initEntriesToggle() {
- const summaryBtn = document.getElementById('btn-entries-toggle');
- const entryList = document.getElementById('entry-list');
+ const summaryBtn = document.getElementById('btn-entries-toggle');
+ const entryList = document.getElementById('entry-list');
if (!summaryBtn || !entryList) return;
- // Immer eingeklappt beim Laden
entryList.classList.add('is-collapsed');
summaryBtn.setAttribute('aria-expanded', 'false');
diff --git a/httpdocs/assets/scripts/registration.js b/httpdocs/assets/scripts/registration.js
index 318b64c..110878d 100644
--- a/httpdocs/assets/scripts/registration.js
+++ b/httpdocs/assets/scripts/registration.js
@@ -1,87 +1,88 @@
// assets/scripts/registration.js
+import { esc, createTranslator } from './utils.js';
+
+const t = createTranslator('Register');
+
document.addEventListener('DOMContentLoaded', () => {
- const form = document.getElementById('register-form');
- const companyInput = document.getElementById('companyName');
- const slugPreview = document.getElementById('slug-preview');
- const submitBtn = document.getElementById('submit-btn');
- const errorBox = document.getElementById('register-errors');
- const appDomain = window.REGISTER_APP_DOMAIN ?? '';
+ const form = document.getElementById('register-form');
+ const companyInput = document.getElementById('companyName');
+ const slugPreview = document.getElementById('slug-preview');
+ const submitBtn = document.getElementById('submit-btn');
+ const errorBox = document.getElementById('register-errors');
+ const appDomain = window.Register?.appDomain ?? '';
+
+ // ── Slug-Vorschau ─────────────────────────────────────────────────────────
+
+ let debounceTimer = null;
+ companyInput?.addEventListener('input', () => {
+ clearTimeout(debounceTimer);
+ debounceTimer = setTimeout(async () => {
+ const value = companyInput.value.trim();
+ if (!value) { slugPreview.textContent = ''; return; }
- // ── Slug-Vorschau ─────────────────────────────────────────────────────────
- let debounce = null;
- companyInput?.addEventListener('input', () => {
- clearTimeout(debounce);
- debounce = setTimeout(async () => {
- const value = companyInput.value.trim();
- if (!value) { slugPreview.textContent = ''; return; }
+ try {
+ const res = await fetch('/api/register/preview-slug', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ companyName: value }),
+ });
+ const data = await res.json();
+ slugPreview.textContent = data.slug ? data.slug + '.' + appDomain : '–';
+ } catch {
+ slugPreview.textContent = '';
+ }
+ }, 350);
+ });
- try {
- const res = await fetch('/api/register/preview-slug', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ companyName: value }),
- });
- const data = await res.json();
- slugPreview.textContent = data.slug ? data.slug + '.' + appDomain : '–';
- } catch {
- slugPreview.textContent = '';
- }
- }, 350);
- });
+ // ── Formular absenden ─────────────────────────────────────────────────────
- // ── Formular absenden ─────────────────────────────────────────────────────
- form?.addEventListener('submit', async (e) => {
- e.preventDefault();
- errorBox.innerHTML = '';
- submitBtn.disabled = true;
- submitBtn.textContent = 'Wird gesendet …';
+ form?.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ errorBox.innerHTML = '';
+ submitBtn.disabled = true;
+ submitBtn.textContent = t('sending');
- const payload = {
- companyName: document.getElementById('companyName').value,
- email: document.getElementById('email').value,
- firstName: document.getElementById('firstName').value,
- lastName: document.getElementById('lastName').value,
- password: document.getElementById('password').value,
- passwordRepeat: document.getElementById('passwordRepeat').value,
- };
+ const payload = {
+ companyName: document.getElementById('companyName').value,
+ email: document.getElementById('email').value,
+ firstName: document.getElementById('firstName').value,
+ lastName: document.getElementById('lastName').value,
+ password: document.getElementById('password').value,
+ passwordRepeat: document.getElementById('passwordRepeat').value,
+ };
- try {
- const res = await fetch('/api/register', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
- });
- const data = await res.json();
+ try {
+ const res = await fetch('/api/register', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ const data = await res.json();
- if (res.ok) {
- document.querySelector('.register-page').innerHTML = `
-
-
✓
-
Fast geschafft!
-
- Wir haben eine Bestätigungs-E-Mail an
- ${payload.email} geschickt.
-
-
- Bitte klicke auf den Link in der E-Mail um dein Konto zu aktivieren.
- Der Link ist 24 Stunden gültig.
-
-
- `;
- } else {
- (data.errors ?? ['Unbekannter Fehler.']).forEach(msg => {
- const p = document.createElement('p');
- p.textContent = msg;
- errorBox.appendChild(p);
- });
- submitBtn.disabled = false;
- submitBtn.textContent = 'Konto erstellen';
- }
- } catch {
- errorBox.innerHTML = '
Verbindungsfehler. Bitte versuche es erneut.
';
- submitBtn.disabled = false;
- submitBtn.textContent = 'Konto erstellen';
- }
- });
-});
\ No newline at end of file
+ if (res.ok) {
+ const text = t('successText').replace('%email%', `
${esc(payload.email)}`);
+ document.querySelector('.register-page').innerHTML = `
+
+
✓
+
${t('successTitle')}
+
${text}
+
${t('successHint')}
+
+ `;
+ } else {
+ (data.errors ?? [t('errorUnknown')]).forEach(msg => {
+ const p = document.createElement('p');
+ p.textContent = msg;
+ errorBox.appendChild(p);
+ });
+ submitBtn.disabled = false;
+ submitBtn.textContent = t('btnSubmit');
+ }
+ } catch {
+ errorBox.innerHTML = `
${esc(t('errorConnection'))}
`;
+ submitBtn.disabled = false;
+ submitBtn.textContent = t('btnSubmit');
+ }
+ });
+});
diff --git a/httpdocs/assets/scripts/report.js b/httpdocs/assets/scripts/report.js
index a46d966..354c632 100644
--- a/httpdocs/assets/scripts/report.js
+++ b/httpdocs/assets/scripts/report.js
@@ -1,477 +1,437 @@
// assets/scripts/report.js
-import {
- parseDuration,
- roundToQuarter,
- formatMinutes,
- validateDuration,
- initDurationBlurHandler,
-} from './duration.js';
-
-// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
-
-function t(key) {
- return window.Report?.i18n?.[key] ?? key;
-}
+import { parseAndValidate, initDurationBlurHandler } from './duration.js';
+import { esc, createTranslator } from './utils.js';
+
+const t = createTranslator('Report');
+
+// ── Hilfsfunktionen ──────────────────────────────────────────────────────────
function populateProjectSelect(select, selectedId) {
- const projects = window.Report?.projects ?? [];
- select.innerHTML = '';
- projects.forEach(p => {
- const opt = document.createElement('option');
- opt.value = p.id;
- opt.textContent = `${p.clientName} / ${p.name}`;
- if (p.id === selectedId) opt.selected = true;
- select.appendChild(opt);
- });
+ const projects = window.Report?.projects ?? [];
+ select.innerHTML = '';
+ projects.forEach(p => {
+ const opt = document.createElement('option');
+ opt.value = p.id;
+ opt.textContent = `${p.clientName} / ${p.name}`;
+ if (p.id === selectedId) opt.selected = true;
+ select.appendChild(opt);
+ });
}
function populateServiceSelect(select, selectedId) {
- const services = window.Report?.services ?? [];
- const billable = services.filter(s => s.billable);
- const notBillable = services.filter(s => !s.billable);
-
- select.innerHTML = `
`;
-
- function addGroup(label, list) {
- if (!list.length) return;
- const group = document.createElement('optgroup');
- group.label = label;
- list.forEach(s => {
- const opt = document.createElement('option');
- opt.value = s.id;
- opt.textContent = s.name;
- if (s.id === selectedId) opt.selected = true;
- group.appendChild(opt);
- });
- select.appendChild(group);
- }
+ const services = window.Report?.services ?? [];
+ const billable = services.filter(s => s.billable);
+ const notBillable = services.filter(s => !s.billable);
+
+ select.innerHTML = `
`;
+
+ function addGroup(label, list) {
+ if (!list.length) return;
+ const group = document.createElement('optgroup');
+ group.label = label;
+ list.forEach(s => {
+ const opt = document.createElement('option');
+ opt.value = s.id;
+ opt.textContent = s.name;
+ if (s.id === selectedId) opt.selected = true;
+ group.appendChild(opt);
+ });
+ select.appendChild(group);
+ }
- addGroup(t('billable'), billable);
- addGroup(t('notBillable'), notBillable);
+ addGroup(t('billable'), billable);
+ addGroup(t('notBillable'), notBillable);
}
-// ── Edit öffnen ───────────────────────────────────────────────────────────────
+// ── Edit öffnen ──────────────────────────────────────────────────────────────
function openEdit(row) {
- document.querySelectorAll('.report-table__row--editing').forEach(r => {
- if (r !== row) closeEdit(r);
- });
+ document.querySelectorAll('.report-table__row--editing').forEach(r => {
+ if (r !== row) closeEdit(r);
+ });
- const editForm = row.querySelector('.report-row__edit');
- if (!editForm) return;
+ const editForm = row.querySelector('.report-row__edit');
+ if (!editForm) return;
- // Selects klonen um akkumulierte Listener zu vermeiden
- const oldProjectSel = row.querySelector('.edit-project');
- const oldServiceSel = row.querySelector('.edit-service');
- const projectSel = oldProjectSel.cloneNode(false);
- const serviceSel = oldServiceSel.cloneNode(false);
- oldProjectSel.replaceWith(projectSel);
- oldServiceSel.replaceWith(serviceSel);
+ const oldProjectSel = row.querySelector('.edit-project');
+ const oldServiceSel = row.querySelector('.edit-service');
+ const projectSel = oldProjectSel.cloneNode(false);
+ const serviceSel = oldServiceSel.cloneNode(false);
+ oldProjectSel.replaceWith(projectSel);
+ oldServiceSel.replaceWith(serviceSel);
- const projectId = parseInt(row.dataset.projectId) || null;
- const serviceId = parseInt(row.dataset.serviceId) || null;
+ const projectId = parseInt(row.dataset.projectId, 10) || null;
+ const serviceId = parseInt(row.dataset.serviceId, 10) || null;
- populateProjectSelect(projectSel, projectId);
- populateServiceSelect(serviceSel, serviceId);
+ populateProjectSelect(projectSel, projectId);
+ populateServiceSelect(serviceSel, serviceId);
- projectSel.addEventListener('change', () => {
- populateServiceSelect(row.querySelector('.edit-service'), null);
- });
+ projectSel.addEventListener('change', () => {
+ populateServiceSelect(row.querySelector('.edit-service'), null);
+ });
- editForm.hidden = false;
- row.classList.add('report-table__row--editing');
- row.querySelector('.edit-duration')?.focus();
+ editForm.hidden = false;
+ row.classList.add('report-table__row--editing');
+ row.querySelector('.edit-duration')?.focus();
}
function closeEdit(row) {
- const editForm = row.querySelector('.report-row__edit');
- if (!editForm) return;
- editForm.hidden = true;
- row.classList.remove('report-table__row--editing');
+ const editForm = row.querySelector('.report-row__edit');
+ if (!editForm) return;
+ editForm.hidden = true;
+ row.classList.remove('report-table__row--editing');
}
-// ── Speichern ─────────────────────────────────────────────────────────────────
+// ── Speichern ────────────────────────────────────────────────────────────────
async function saveEdit(row) {
- const id = row.dataset.entryId;
- const durationRaw = row.querySelector('.edit-duration')?.value ?? '0:00';
- const projectId = row.querySelector('.edit-project')?.value;
- const serviceId = row.querySelector('.edit-service')?.value;
- const note = row.querySelector('.edit-note')?.value ?? '';
-
- if (!projectId) { alert(t('errorNoProject')); return; }
-
- const rawMinutes = roundToQuarter(parseDuration(durationRaw));
-
- if (rawMinutes === 0) { alert(t('errorZeroDuration')); return; }
-
- const validation = validateDuration(rawMinutes);
- if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; }
- if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return;
-
- try {
- const res = await fetch(`/api/entries/${id}`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- duration: formatMinutes(rawMinutes),
- projectId: parseInt(projectId),
- serviceId: serviceId ? parseInt(serviceId) : null,
- note: note || null,
- }),
- });
-
- if (!res.ok) {
- const err = await res.json().catch(() => ({}));
- alert(err.error ?? t('errorSave'));
- return;
- }
-
- window.location.reload();
+ const saveBtn = row.querySelector('[data-action="save"]');
+ if (saveBtn?.disabled) return;
+
+ const id = row.dataset.entryId;
+ const projectId = row.querySelector('.edit-project')?.value;
+ const serviceId = row.querySelector('.edit-service')?.value;
+ const note = row.querySelector('.edit-note')?.value ?? '';
+
+ if (!projectId) { alert(t('errorNoProject')); return; }
+
+ const dur = parseAndValidate(row.querySelector('.edit-duration')?.value ?? '0:00');
+ if (dur.error) { alert(t(dur.error)); return; }
+ if (dur.warn && !confirm(t(dur.warn))) return;
+
+ if (saveBtn) saveBtn.disabled = true;
+ try {
+ const res = await fetch(`/api/entries/${id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ duration: dur.formatted,
+ projectId: parseInt(projectId, 10),
+ serviceId: serviceId ? parseInt(serviceId, 10) : null,
+ note: note || null,
+ }),
+ });
- } catch {
- alert(t('errorSave'));
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ alert(err.error ?? t('errorSave'));
+ return;
}
+
+ window.location.reload();
+ } catch {
+ alert(t('errorSave'));
+ } finally {
+ if (saveBtn) saveBtn.disabled = false;
+ }
}
-// ── Löschen ───────────────────────────────────────────────────────────────────
+// ── Löschen ──────────────────────────────────────────────────────────────────
async function deleteEntry(row) {
- if (!confirm(t('confirmDelete'))) return;
-
- const id = row.dataset.entryId;
-
- try {
- const res = await fetch(`/api/entries/${id}`, { method: 'DELETE' });
- if (!res.ok) { alert(t('errorDelete')); return; }
- window.location.reload();
- } catch {
- alert(t('errorDelete'));
- }
+ if (!confirm(t('confirmDelete'))) return;
+
+ try {
+ const res = await fetch(`/api/entries/${row.dataset.entryId}`, { method: 'DELETE' });
+ if (!res.ok) { alert(t('errorDelete')); return; }
+ window.location.reload();
+ } catch {
+ alert(t('errorDelete'));
+ }
}
-// ── Abgerechnet toggeln ───────────────────────────────────────────────────────
+// ── Abgerechnet toggeln ──────────────────────────────────────────────────────
async function toggleInvoiced(row) {
- const id = row.dataset.entryId;
- const btn = row.querySelector('[data-action="toggle-invoiced"]');
+ const id = row.dataset.entryId;
+ const btn = row.querySelector('[data-action="toggle-invoiced"]');
- try {
- const res = await fetch(`/api/entries/${id}/invoiced`, { method: 'PATCH' });
- if (!res.ok) return;
+ try {
+ const res = await fetch(`/api/entries/${id}/invoiced`, { method: 'PATCH' });
+ if (!res.ok) return;
- const data = await res.json();
- const invoiced = data.invoiced;
+ const data = await res.json();
+ const invoiced = data.invoiced;
- row.dataset.invoiced = invoiced ? 'true' : 'false';
- row.classList.toggle('report-table__row--invoiced', invoiced);
+ row.dataset.invoiced = invoiced ? 'true' : 'false';
+ row.classList.toggle('report-table__row--invoiced', invoiced);
- if (btn) {
- btn.classList.toggle('report-lock--invoiced', invoiced);
- btn.title = invoiced ? t('btnUnlock') : t('btnLock');
- }
-
- } catch (err) {
- console.error('Fehler beim Toggeln des Abrechnungsstatus:', err);
+ if (btn) {
+ btn.classList.toggle('report-lock--invoiced', invoiced);
+ btn.title = invoiced ? t('btnUnlock') : t('btnLock');
}
+ } catch (err) {
+ console.error('toggleInvoiced error:', err);
+ }
}
-// ── Event-Delegation ──────────────────────────────────────────────────────────
+// ── Event-Delegation ─────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
- initDurationBlurHandler();
-
- const table = document.querySelector('.report-table');
- if (!table) return;
+ initDurationBlurHandler();
+ const table = document.querySelector('.report-table');
+ if (table) {
table.addEventListener('click', e => {
- const btn = e.target.closest('[data-action]');
- if (!btn) return;
-
- const row = btn.closest('.report-table__row');
- if (!row) return;
-
- switch (btn.dataset.action) {
- case 'edit': openEdit(row); break;
- case 'cancel': closeEdit(row); break;
- case 'save': saveEdit(row); break;
- case 'delete': deleteEntry(row); break;
- case 'toggle-invoiced': toggleInvoiced(row); break;
- }
+ const btn = e.target.closest('[data-action]');
+ if (!btn) return;
+
+ const row = btn.closest('.report-table__row');
+ if (!row) return;
+
+ switch (btn.dataset.action) {
+ case 'edit': openEdit(row); break;
+ case 'cancel': closeEdit(row); break;
+ case 'save': saveEdit(row); break;
+ case 'delete': deleteEntry(row); break;
+ case 'toggle-invoiced': toggleInvoiced(row); break;
+ }
});
+ }
+
+ new ReportFilter().init();
+ initExportButtons();
+ initPrintButton();
});
-// ── ReportFilter ──────────────────────────────────────────────────────────────
+// ── ReportFilter ─────────────────────────────────────────────────────────────
class ReportFilter {
- constructor() {
- this.panel = document.getElementById('report-filter');
- this.toggleBtn = document.getElementById('btn-filter-toggle');
- this.applyBtn = document.getElementById('btn-filter-apply');
- this.hideBtn = document.getElementById('btn-filter-hide');
- this.periodSel = document.querySelector('.filter-period-select');
- this.customDates = document.querySelector('.filter-custom-dates');
- }
-
- init() {
- if (!this.panel) return;
+ constructor() {
+ this.panel = document.getElementById('report-filter');
+ this.toggleBtn = document.getElementById('btn-filter-toggle');
+ this.applyBtn = document.getElementById('btn-filter-apply');
+ this.hideBtn = document.getElementById('btn-filter-hide');
+ this.periodSel = document.querySelector('.filter-period-select');
+ this.customDates = document.querySelector('.filter-custom-dates');
+ }
+
+ init() {
+ if (!this.panel) return;
+
+ this.toggleBtn?.addEventListener('click', () => this.togglePanel());
+ this.hideBtn?.addEventListener('click', () => this.hidePanel());
+ this.applyBtn?.addEventListener('click', () => this.applyFilters());
+
+ this.panel.querySelectorAll('.filter-row__checkbox').forEach(cb => {
+ cb.addEventListener('change', () => {
+ this.syncRowState(cb.closest('.filter-row'), cb.checked);
+ });
+ });
- // Toolbar-Toggle
- this.toggleBtn?.addEventListener('click', () => this.togglePanel());
+ this.panel.querySelectorAll('.filter-select, .filter-note-input').forEach(el => {
+ el.addEventListener('mousedown', () => this.activateRowByControl(el));
+ });
- // Ausblenden-Button
- this.hideBtn?.addEventListener('click', () => this.hidePanel());
+ this.periodSel?.addEventListener('change', () => {
+ this.activateRowByControl(this.periodSel);
+ this.toggleCustomDates(this.periodSel.value === 'custom');
+ });
- // Filtern-Button
- this.applyBtn?.addEventListener('click', () => this.applyFilters());
+ this.panel.querySelectorAll('.filter-row__add').forEach(btn => {
+ btn.addEventListener('click', () => this.addControl(btn));
+ });
- // Checkbox-Änderungen
- this.panel.querySelectorAll('.filter-row__checkbox').forEach(cb => {
- cb.addEventListener('change', () => {
- const row = cb.closest('.filter-row');
- this.syncRowState(row, cb.checked);
- });
- });
+ this.panel.addEventListener('click', e => {
+ const removeBtn = e.target.closest('.filter-row__remove');
+ if (removeBtn) this.removeControl(removeBtn);
+ });
- // Klick auf ausgegrautem Control → Checkbox aktivieren
- this.panel.querySelectorAll('.filter-select, .filter-note-input').forEach(el => {
- el.addEventListener('mousedown', () => this.activateRowByControl(el));
- });
+ this.panel.addEventListener('change', e => {
+ const sel = e.target.closest('.filter-select');
+ if (!sel) return;
+ const container = sel.closest('.filter-row__controls');
+ if (container) this.refreshGroupSelects(container);
+ });
- // Zeitraum-Select → Custom-Felder zeigen/verstecken
- this.periodSel?.addEventListener('change', () => {
- const row = this.periodSel.closest('.filter-row');
- this.activateRowByControl(this.periodSel);
- this.toggleCustomDates(this.periodSel.value === 'custom');
- });
+ this.panel.querySelectorAll('.filter-row').forEach(row => {
+ const cb = row.querySelector('.filter-row__checkbox');
+ this.syncRowState(row, cb?.checked ?? false);
+ });
- // Plus-Buttons
- this.panel.querySelectorAll('.filter-row__add').forEach(btn => {
- btn.addEventListener('click', () => this.addControl(btn));
- });
+ this.panel.querySelectorAll('.filter-row__controls').forEach(container => {
+ this.refreshGroupSelects(container);
+ });
+ }
+
+ togglePanel() {
+ const isHidden = this.panel.hasAttribute('hidden');
+ if (isHidden) {
+ this.panel.removeAttribute('hidden');
+ this.toggleBtn?.classList.add('report-toolbar__action--active');
+ } else {
+ this.hidePanel();
+ }
+ }
+
+ hidePanel() {
+ this.panel.setAttribute('hidden', '');
+ this.toggleBtn?.classList.remove('report-toolbar__action--active');
+ }
+
+ syncRowState(row, active) {
+ row.classList.toggle('filter-row--inactive', !active);
+ }
+
+ activateRowByControl(el) {
+ const row = el.closest('.filter-row');
+ if (!row) return;
+ const cb = row.querySelector('.filter-row__checkbox');
+ if (cb && !cb.checked) {
+ cb.checked = true;
+ this.syncRowState(row, true);
+ }
+ }
- // Remove-Buttons (via Delegation, da sie dynamisch entstehen)
- this.panel.addEventListener('click', e => {
- const removeBtn = e.target.closest('.filter-row__remove');
- if (removeBtn) this.removeControl(removeBtn);
- });
+ toggleCustomDates(show) {
+ if (!this.customDates) return;
+ this.customDates.toggleAttribute('hidden', !show);
+ }
- // Select-Änderung → Optionen in der Gruppe aktualisieren
- this.panel.addEventListener('change', e => {
- const sel = e.target.closest('.filter-select');
- if (!sel) return;
- const container = sel.closest('.filter-row__controls');
- if (container) this.refreshGroupSelects(container);
- });
+ addControl(btn) {
+ const targetId = btn.dataset.target;
+ const container = document.getElementById(targetId);
+ if (!container) return;
- // Initialer Zustand
- this.panel.querySelectorAll('.filter-row').forEach(row => {
- const cb = row.querySelector('.filter-row__checkbox');
- this.syncRowState(row, cb?.checked ?? false);
- });
+ const template = container.querySelector('.filter-row__control-group');
+ if (!template) return;
- // Bereits geladene Mehrfach-Selects deduplizieren (nach Seiten-Reload mit Filtern)
- this.panel.querySelectorAll('.filter-row__controls').forEach(container => {
- this.refreshGroupSelects(container);
- });
- }
+ const clone = template.cloneNode(true);
- // ── Panel toggeln ─────────────────────────────────────────────────────────
+ const clonedSelect = clone.querySelector('.filter-select');
+ if (clonedSelect) clonedSelect.value = '';
- togglePanel() {
- const isHidden = this.panel.hasAttribute('hidden');
- if (isHidden) {
- this.panel.removeAttribute('hidden');
- this.toggleBtn?.classList.add('report-toolbar__action--active');
- } else {
- this.hidePanel();
- }
+ if (!clone.querySelector('.filter-row__remove')) {
+ const removeBtn = document.createElement('button');
+ removeBtn.type = 'button';
+ removeBtn.className = 'filter-row__remove';
+ removeBtn.textContent = '×';
+ clone.appendChild(removeBtn);
}
- hidePanel() {
- this.panel.setAttribute('hidden', '');
- this.toggleBtn?.classList.remove('report-toolbar__action--active');
- }
+ clone.querySelector('.filter-select')?.addEventListener('mousedown', () => {
+ this.activateRowByControl(clone.querySelector('.filter-select'));
+ });
- // ── Row-Zustand (aktiv / inaktiv) ─────────────────────────────────────────
+ container.appendChild(clone);
+ this.refreshGroupSelects(container);
- syncRowState(row, active) {
- row.classList.toggle('filter-row--inactive', !active);
+ const row = btn.closest('.filter-row');
+ const cb = row?.querySelector('.filter-row__checkbox');
+ if (cb && !cb.checked) {
+ cb.checked = true;
+ this.syncRowState(row, true);
}
- activateRowByControl(el) {
- const row = el.closest('.filter-row');
- if (!row) return;
- const cb = row.querySelector('.filter-row__checkbox');
- if (cb && !cb.checked) {
- cb.checked = true;
- this.syncRowState(row, true);
- }
+ clonedSelect?.focus();
+ }
+
+ removeControl(removeBtn) {
+ const group = removeBtn.closest('.filter-row__control-group');
+ const container = group?.parentElement;
+ group?.remove();
+
+ if (container && !container.querySelector('.filter-row__control-group')) {
+ const row = container.closest('.filter-row');
+ const cb = row?.querySelector('.filter-row__checkbox');
+ if (cb) {
+ cb.checked = false;
+ this.syncRowState(row, false);
+ }
}
- // ── Zeitraum: Custom-Felder ────────────────────────────────────────────────
-
- toggleCustomDates(show) {
- if (!this.customDates) return;
- if (show) {
- this.customDates.removeAttribute('hidden');
- } else {
- this.customDates.setAttribute('hidden', '');
- }
- }
+ if (container) this.refreshGroupSelects(container);
+ }
- // ── Plus: weiteres Control hinzufügen ─────────────────────────────────────
+ refreshGroupSelects(container) {
+ const selects = [...container.querySelectorAll('.filter-select')];
+ if (selects.length < 2) return;
- addControl(btn) {
- const targetId = btn.dataset.target;
- const filterKey = btn.dataset.filterKey;
- const container = document.getElementById(targetId);
- if (!container) return;
+ const selectedValues = new Set(
+ selects.map(s => s.value).filter(v => v !== '')
+ );
- // Erste Gruppe als Template klonen
- const template = container.querySelector('.filter-row__control-group');
- if (!template) return;
+ selects.forEach(sel => {
+ const ownValue = sel.value;
+ sel.querySelectorAll('option').forEach(opt => {
+ if (!opt.value) return;
+ opt.hidden = selectedValues.has(opt.value) && opt.value !== ownValue;
+ });
+ });
+ }
- const clone = template.cloneNode(true);
+ applyFilters() {
+ const params = new URLSearchParams();
+ params.set('limit', String(window.Report?.limit ?? 50));
- // Select zurücksetzen
- const clonedSelect = clone.querySelector('.filter-select');
- if (clonedSelect) clonedSelect.value = '';
+ this.panel.querySelectorAll('.filter-row').forEach(row => {
+ const cb = row.querySelector('.filter-row__checkbox');
+ if (!cb?.checked) return;
- // Remove-Button hinzufügen (falls noch keiner da)
- if (!clone.querySelector('.filter-row__remove')) {
- const removeBtn = document.createElement('button');
- removeBtn.type = 'button';
- removeBtn.className = 'filter-row__remove';
- removeBtn.textContent = '×';
- clone.appendChild(removeBtn);
- }
+ const key = row.dataset.filterKey;
- // Neu: Klick auf den geklonten Select aktiviert ebenfalls die Row
- clone.querySelector('.filter-select')?.addEventListener('mousedown', () => {
- this.activateRowByControl(clone.querySelector('.filter-select'));
+ if (['clients', 'projects', 'services', 'users'].includes(key)) {
+ row.querySelectorAll('.filter-select').forEach(sel => {
+ if (sel.value) params.append(`filter[${key}][]`, sel.value);
});
-
- container.appendChild(clone);
-
- // Optionen deduplizieren
- this.refreshGroupSelects(container);
-
- // Row aktivieren
- const row = btn.closest('.filter-row');
- const cb = row?.querySelector('.filter-row__checkbox');
- if (cb && !cb.checked) {
- cb.checked = true;
- this.syncRowState(row, true);
+ if (row.querySelector('.filter-neg-checkbox')?.checked) {
+ params.set(`filter[${key}_neg]`, '1');
}
- clonedSelect?.focus();
- }
-
- // ── Minus: Control entfernen ──────────────────────────────────────────────
-
- removeControl(removeBtn) {
- const group = removeBtn.closest('.filter-row__control-group');
- const container = group?.parentElement;
- group?.remove();
-
- // Wenn keine Controls mehr übrig → Checkbox deaktivieren
- if (container && !container.querySelector('.filter-row__control-group')) {
- const row = container.closest('.filter-row');
- const cb = row?.querySelector('.filter-row__checkbox');
- if (cb) {
- cb.checked = false;
- this.syncRowState(row, false);
- }
+ } else if (key === 'period') {
+ const val = this.periodSel?.value;
+ if (!val) return;
+ params.set('filter[period]', val);
+
+ if (val === 'custom' && this.customDates) {
+ const get = field => this.customDates.querySelector(`[data-date-field="${field}"]`)?.value ?? '';
+ const fromDay = get('from-day').padStart(2, '0');
+ const fromMonth = get('from-month').padStart(2, '0');
+ const fromYear = get('from-year');
+ const toDay = get('to-day').padStart(2, '0');
+ const toMonth = get('to-month').padStart(2, '0');
+ const toYear = get('to-year');
+
+ if (fromYear && fromMonth && fromDay) {
+ params.set('filter[date_from]', `${fromYear}-${fromMonth}-${fromDay}`);
+ }
+ if (toYear && toMonth && toDay) {
+ params.set('filter[date_to]', `${toYear}-${toMonth}-${toDay}`);
+ }
+ }
+ if (row.querySelector('.filter-neg-checkbox')?.checked) {
+ params.set('filter[period_neg]', '1');
}
- // Verbleibende Selects aktualisieren
- if (container) this.refreshGroupSelects(container);
- }
-
- // ── Optionen in Mehrfach-Selects deduplizieren ────────────────────────────
-
- refreshGroupSelects(container) {
- const selects = [...container.querySelectorAll('.filter-select')];
- if (selects.length < 2) return;
+ } else if (key === 'note') {
+ const val = row.querySelector('.filter-note-input')?.value?.trim();
+ if (val) params.set('filter[note]', val);
- // Alle gewählten Values sammeln
- const selectedValues = new Set(
- selects.map(s => s.value).filter(v => v !== '')
- );
+ } else if (key === 'invoiced') {
+ const checked = row.querySelector('.filter-invoiced-radio:checked');
+ if (checked) params.set('filter[invoiced]', checked.value);
+ }
+ });
- selects.forEach(sel => {
- const ownValue = sel.value;
- sel.querySelectorAll('option').forEach(opt => {
- if (!opt.value) return; // "..." immer sichtbar lassen
- // Verstecken wenn woanders gewählt, aber nicht beim eigenen Select
- opt.hidden = selectedValues.has(opt.value) && opt.value !== ownValue;
- });
- });
- }
+ window.location.href = `/reports/times?${params}`;
+ }
+}
- // ── Filter anwenden → URL bauen und navigieren ────────────────────────────
-
- applyFilters() {
- const params = new URLSearchParams();
- params.set('limit', String(window.Report?.limit ?? 50));
-
- this.panel.querySelectorAll('.filter-row').forEach(row => {
- const cb = row.querySelector('.filter-row__checkbox');
- if (!cb?.checked) return;
-
- const key = row.dataset.filterKey;
-
- if (['clients', 'projects', 'services', 'users'].includes(key)) {
- row.querySelectorAll('.filter-select').forEach(sel => {
- if (sel.value) params.append(`filter[${key}][]`, sel.value);
- });
- if (row.querySelector('.filter-neg-checkbox')?.checked) {
- params.set(`filter[${key}_neg]`, '1');
- }
-
- } else if (key === 'period') {
- const val = this.periodSel?.value;
- if (!val) return;
- params.set('filter[period]', val);
-
- if (val === 'custom' && this.customDates) {
- const get = field => this.customDates.querySelector(`[data-date-field="${field}"]`)?.value ?? '';
- const fromDay = get('from-day').padStart(2, '0');
- const fromMonth = get('from-month').padStart(2, '0');
- const fromYear = get('from-year');
- const toDay = get('to-day').padStart(2, '0');
- const toMonth = get('to-month').padStart(2, '0');
- const toYear = get('to-year');
-
- if (fromYear && fromMonth && fromDay) {
- params.set('filter[date_from]', `${fromYear}-${fromMonth}-${fromDay}`);
- }
- if (toYear && toMonth && toDay) {
- params.set('filter[date_to]', `${toYear}-${toMonth}-${toDay}`);
- }
- }
- if (row.querySelector('.filter-neg-checkbox')?.checked) {
- params.set('filter[period_neg]', '1');
- }
-
- } else if (key === 'note') {
- const val = row.querySelector('.filter-note-input')?.value?.trim();
- if (val) params.set('filter[note]', val);
-
- } else if (key === 'invoiced') {
- const checked = row.querySelector('.filter-invoiced-radio:checked');
- if (checked) params.set('filter[invoiced]', checked.value);
- }
- });
+// ── Export ────────────────────────────────────────────────────────────────────
- window.location.href = `/reports/times?${params}`;
- }
+function initExportButtons() {
+ ['excel', 'csv', 'pdf'].forEach(format => {
+ document.getElementById(`btn-export-${format}`)?.addEventListener('click', () => {
+ const params = new URLSearchParams(window.location.search);
+ params.delete('limit');
+ window.location.href = `/reports/export/${format}?${params}`;
+ });
+ });
}
-// ── Init ──────────────────────────────────────────────────────────────────────
-
-document.addEventListener('DOMContentLoaded', () => {
- new ReportFilter().init();
-});
+function initPrintButton() {
+ document.getElementById('btn-print')?.addEventListener('click', () => {
+ window.print();
+ });
+}
diff --git a/httpdocs/assets/scripts/team.js b/httpdocs/assets/scripts/team.js
index 8664d5f..92ef1b1 100644
--- a/httpdocs/assets/scripts/team.js
+++ b/httpdocs/assets/scripts/team.js
@@ -1,223 +1,261 @@
-// team.js
+// assets/scripts/team.js
+
+import { esc, createTranslator, ANIMATION_MS, removeWithAnimation } from './utils.js';
+
+const t = createTranslator('Team');
+
document.addEventListener('DOMContentLoaded', () => {
- // ── Tabs ─────────────────────────────────────────────────────────────────────
- document.querySelectorAll('.crud-tab').forEach(tab => {
- tab.addEventListener('click', () => {
- document.querySelectorAll('.crud-tab').forEach(t =>
- t.classList.toggle('crud-tab--active', t === tab)
- );
- document.querySelectorAll('[data-tab-panel]').forEach(panel => {
- panel.hidden = panel.dataset.tabPanel !== tab.dataset.tab;
- });
- });
- });
+ // ── Tabs ──────────────────────────────────────────────────────────────────
- // ── Einlade-Modal ─────────────────────────────────────────────────────────────
- const modal = document.getElementById('team-modal');
- const errorsBox = document.getElementById('team-modal-errors');
-
- const openModal = () => { modal.hidden = false; };
- const closeModal = () => {
- modal.hidden = true;
- errorsBox.hidden = true;
- ['inv-firstName', 'inv-lastName', 'inv-email'].forEach(id => {
- document.getElementById(id).value = '';
- });
- const defaultRole = modal.querySelector('input[name="inv-role"][value="member"]');
- if (defaultRole) defaultRole.checked = true;
- };
+ document.querySelectorAll('.crud-tab').forEach(tab => {
+ tab.addEventListener('click', () => {
+ document.querySelectorAll('.crud-tab').forEach(t =>
+ t.classList.toggle('crud-tab--active', t === tab)
+ );
+ document.querySelectorAll('[data-tab-panel]').forEach(panel => {
+ panel.hidden = panel.dataset.tabPanel !== tab.dataset.tab;
+ });
+ });
+ });
- document.getElementById('team-invite-btn').addEventListener('click', openModal);
- document.getElementById('team-modal-close').addEventListener('click', closeModal);
- document.getElementById('team-modal-cancel').addEventListener('click', closeModal);
- modal.addEventListener('click', e => { if (e.target === modal) closeModal(); });
-
- document.getElementById('team-modal-submit').addEventListener('click', async () => {
- const payload = {
- firstName: document.getElementById('inv-firstName').value.trim(),
- lastName: document.getElementById('inv-lastName').value.trim(),
- email: document.getElementById('inv-email').value.trim(),
- role: modal.querySelector('input[name="inv-role"]:checked')?.value ?? 'member',
- };
-
- const res = await fetch('/api/team/invite', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
- });
- const data = await res.json();
+ // ── Einlade-Modal ─────────────────────────────────────────────────────────
- if (!res.ok) {
- errorsBox.hidden = false;
- errorsBox.innerHTML = '
' + (data.errors ?? [data.error]).map(e => `- ${e}
`).join('') + '
';
- return;
- }
+ const modal = document.getElementById('team-modal');
+ const errorsBox = document.getElementById('team-modal-errors');
- closeModal();
- window.location.reload();
+ const openModal = () => { modal.hidden = false; };
+ const closeModal = () => {
+ modal.hidden = true;
+ errorsBox.hidden = true;
+ ['inv-firstName', 'inv-lastName', 'inv-email'].forEach(id => {
+ document.getElementById(id).value = '';
});
+ const defaultRole = modal.querySelector('input[name="inv-role"][value="member"]');
+ if (defaultRole) defaultRole.checked = true;
+ };
+
+ document.getElementById('team-invite-btn').addEventListener('click', openModal);
+ document.getElementById('team-modal-close').addEventListener('click', closeModal);
+ document.getElementById('team-modal-cancel').addEventListener('click', closeModal);
+ modal.addEventListener('click', e => { if (e.target === modal) closeModal(); });
+
+ const submitBtn = document.getElementById('team-modal-submit');
+ submitBtn.addEventListener('click', async () => {
+ const payload = {
+ firstName: document.getElementById('inv-firstName').value.trim(),
+ lastName: document.getElementById('inv-lastName').value.trim(),
+ email: document.getElementById('inv-email').value.trim(),
+ role: modal.querySelector('input[name="inv-role"]:checked')?.value ?? 'member',
+ };
- // ── Listen-Delegation: aktive User + Einladungen ───────────────────────────
- const list = document.getElementById('team-list');
- if (list) {
- list.addEventListener('click', e => {
- const actionEl = e.target.closest('[data-action]');
- if (!actionEl) return;
-
- const action = actionEl.dataset.action;
- const row = e.target.closest('.crud-row');
- if (!row) return;
-
- switch (action) {
- case 'edit': openEdit(row); break;
- case 'save': saveEdit(row); break;
- case 'cancel': closeEdit(row); break;
- case 'delete': deleteMember(row); break;
- case 'delete-invite': deleteInvite(actionEl.dataset.id, row); break;
- }
- });
+ submitBtn.disabled = true;
+ try {
+ const res = await fetch('/api/team/invite', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ const data = await res.json();
+
+ if (!res.ok) {
+ errorsBox.hidden = false;
+ const errors = data.errors ?? [data.error];
+ errorsBox.innerHTML = '
' + errors.map(e => `- ${esc(e)}
`).join('') + '
';
+ return;
+ }
+
+ closeModal();
+ window.location.reload();
+ } catch {
+ errorsBox.hidden = false;
+ errorsBox.innerHTML = `
`;
+ } finally {
+ submitBtn.disabled = false;
}
+ });
+
+ // ── Listen-Delegation: aktive User + Einladungen ──────────────────────────
+
+ const list = document.getElementById('team-list');
+ if (list) {
+ list.addEventListener('click', e => {
+ const actionEl = e.target.closest('[data-action]');
+ if (!actionEl) return;
+
+ const row = e.target.closest('.crud-row');
+ if (!row) return;
+
+ switch (actionEl.dataset.action) {
+ case 'edit': openEdit(row); break;
+ case 'save': saveEdit(row); break;
+ case 'cancel': closeEdit(row); break;
+ case 'delete': deleteMember(row); break;
+ case 'delete-invite': deleteInvite(actionEl.dataset.id, row); break;
+ }
+ });
+ }
- // ── Listen-Delegation: archivierte User ───────────────────────────────────
- const archivedList = document.getElementById('team-list-archived');
- if (archivedList) {
- archivedList.addEventListener('click', e => {
- const actionEl = e.target.closest('[data-action]');
- if (!actionEl) return;
- const row = e.target.closest('.crud-row');
- if (!row) return;
-
- if (actionEl.dataset.action === 'unarchive') {
- unarchiveMember(row);
- }
- });
- }
+ // ── Listen-Delegation: archivierte User ───────────────────────────────────
- // ── Inline Edit ───────────────────────────────────────────────────────────
- function openEdit(row) {
- row.querySelector('.crud-row__display').hidden = true;
- row.querySelector('.crud-row__edit').hidden = false;
- row.querySelector('.edit-first-name')?.focus();
- }
+ const archivedList = document.getElementById('team-list-archived');
+ if (archivedList) {
+ archivedList.addEventListener('click', e => {
+ const actionEl = e.target.closest('[data-action]');
+ if (!actionEl) return;
+ const row = e.target.closest('.crud-row');
+ if (!row) return;
- function closeEdit(row) {
- row.querySelector('.crud-row__display').hidden = false;
- row.querySelector('.crud-row__edit').hidden = true;
+ if (actionEl.dataset.action === 'unarchive') {
+ unarchiveMember(row);
+ }
+ });
+ }
- // Felder auf ursprüngliche Werte zurücksetzen
- row.querySelector('.edit-first-name').value = row.dataset.firstName ?? '';
- row.querySelector('.edit-last-name').value = row.dataset.lastName ?? '';
- row.querySelector('.edit-email').value = row.dataset.email ?? '';
- row.querySelector('.edit-note').value = row.dataset.note ?? '';
+ // ── Inline Edit ───────────────────────────────────────────────────────────
- const currentRole = row.dataset.role;
- row.querySelectorAll('.edit-role').forEach(radio => {
- radio.checked = radio.value === currentRole;
- });
- }
+ function openEdit(row) {
+ row.querySelector('.crud-row__display').hidden = true;
+ row.querySelector('.crud-row__edit').hidden = false;
+ row.querySelector('.edit-first-name')?.focus();
+ }
- async function saveEdit(row) {
- const id = row.dataset.id;
- const firstName = row.querySelector('.edit-first-name').value.trim();
- const lastName = row.querySelector('.edit-last-name').value.trim();
- const email = row.querySelector('.edit-email').value.trim();
- const note = row.querySelector('.edit-note').value || null;
- const role = row.querySelector('.edit-role:checked')?.value ?? row.dataset.role;
-
- const res = await fetch(`/api/team/${id}`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ firstName, lastName, email, note, role }),
- });
-
- if (!res.ok) {
- const data = await res.json();
- alert((data.errors ?? [data.error]).join('\n'));
- return;
- }
+ function closeEdit(row) {
+ row.querySelector('.crud-row__display').hidden = false;
+ row.querySelector('.crud-row__edit').hidden = true;
- const data = await res.json();
- updateDisplay(row, data);
- closeEdit(row);
- }
+ row.querySelector('.edit-first-name').value = row.dataset.firstName ?? '';
+ row.querySelector('.edit-last-name').value = row.dataset.lastName ?? '';
+ row.querySelector('.edit-email').value = row.dataset.email ?? '';
+ row.querySelector('.edit-note').value = row.dataset.note ?? '';
- function updateDisplay(row, data) {
- row.querySelector('.crud-row__name').textContent = data.fullName;
- row.querySelector('.crud-row__meta').textContent = `(${data.roleLabel})`;
-
- row.dataset.firstName = data.firstName;
- row.dataset.lastName = data.lastName;
- row.dataset.email = data.email;
- row.dataset.note = data.note ?? '';
- row.dataset.role = data.role;
-
- // Edit-Felder aktualisieren
- row.querySelector('.edit-first-name').value = data.firstName;
- row.querySelector('.edit-last-name').value = data.lastName;
- row.querySelector('.edit-email').value = data.email;
- row.querySelector('.edit-note').value = data.note ?? '';
- row.querySelectorAll('.edit-role').forEach(radio => {
- radio.checked = radio.value === data.role;
- });
+ const currentRole = row.dataset.role;
+ row.querySelectorAll('.edit-role').forEach(radio => {
+ radio.checked = radio.value === currentRole;
+ });
+ }
+
+ async function saveEdit(row) {
+ const saveBtn = row.querySelector('[data-action="save"]');
+ if (saveBtn?.disabled) return;
+
+ const id = row.dataset.id;
+ const firstName = row.querySelector('.edit-first-name').value.trim();
+ const lastName = row.querySelector('.edit-last-name').value.trim();
+ const email = row.querySelector('.edit-email').value.trim();
+ const note = row.querySelector('.edit-note').value || null;
+ const role = row.querySelector('.edit-role:checked')?.value ?? row.dataset.role;
+
+ if (saveBtn) saveBtn.disabled = true;
+ try {
+ const res = await fetch(`/api/team/${id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ firstName, lastName, email, note, role }),
+ });
+
+ if (!res.ok) {
+ const data = await res.json();
+ alert((data.errors ?? [data.error]).join('\n'));
+ return;
+ }
+
+ const data = await res.json();
+ updateDisplay(row, data);
+ closeEdit(row);
+ } catch {
+ alert(t('errorSave'));
+ } finally {
+ if (saveBtn) saveBtn.disabled = false;
}
+ }
+
+ function updateDisplay(row, data) {
+ row.querySelector('.crud-row__name').textContent = data.fullName;
+ row.querySelector('.crud-row__meta').textContent = `(${data.roleLabel})`;
+
+ row.dataset.firstName = data.firstName;
+ row.dataset.lastName = data.lastName;
+ row.dataset.email = data.email;
+ row.dataset.note = data.note ?? '';
+ row.dataset.role = data.role;
+
+ row.querySelector('.edit-first-name').value = data.firstName;
+ row.querySelector('.edit-last-name').value = data.lastName;
+ row.querySelector('.edit-email').value = data.email;
+ row.querySelector('.edit-note').value = data.note ?? '';
+ row.querySelectorAll('.edit-role').forEach(radio => {
+ radio.checked = radio.value === data.role;
+ });
+ }
- // ── Delete ────────────────────────────────────────────────────────────────
- async function deleteMember(row) {
- if (!confirm('Wirklich entfernen?')) return;
+ // ── Delete ────────────────────────────────────────────────────────────────
- const id = row.dataset.id;
- const res = await fetch(`/api/team/${id}`, { method: 'DELETE' });
+ async function deleteMember(row) {
+ if (!confirm(t('confirmDelete'))) return;
- if (res.status === 409) {
- if (confirm('Dieser Benutzer hat Zeiteinträge und kann nicht gelöscht werden.\nStattdessen archivieren?')) {
- await archiveMember(row);
- }
- return;
- }
+ try {
+ const res = await fetch(`/api/team/${row.dataset.id}`, { method: 'DELETE' });
- if (!res.ok) {
- const data = await res.json();
- alert(data.error ?? 'Fehler beim Löschen.');
- return;
+ if (res.status === 409) {
+ if (confirm(t('confirmArchive'))) {
+ await archiveMember(row);
}
-
- row.classList.add('crud-row--removing');
- setTimeout(() => row.remove(), 280);
+ return;
+ }
+
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ alert(data.error ?? t('errorDelete'));
+ return;
+ }
+
+ removeWithAnimation(row, 'crud-row--removing');
+ } catch {
+ alert(t('errorDelete'));
}
-
- async function deleteInvite(id, row) {
- if (!confirm('Einladung zurückziehen?')) return;
-
- const res = await fetch(`/api/team/invite/${id}`, { method: 'DELETE' });
- if (!res.ok) {
- const data = await res.json();
- alert(data.error ?? 'Fehler');
- return;
- }
-
- row.classList.add('crud-row--removing');
- setTimeout(() => row.remove(), 280);
+ }
+
+ async function deleteInvite(id, row) {
+ if (!confirm(t('confirmRevokeInvite'))) return;
+
+ try {
+ const res = await fetch(`/api/team/invite/${id}`, { method: 'DELETE' });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ alert(data.error ?? t('errorGeneric'));
+ return;
+ }
+
+ removeWithAnimation(row, 'crud-row--removing');
+ } catch {
+ alert(t('errorGeneric'));
}
+ }
- // ── Archive / Unarchive ───────────────────────────────────────────────────
- async function archiveMember(row) {
- const id = row.dataset.id;
- const res = await fetch(`/api/team/${id}/archive`, { method: 'PATCH' });
+ // ── Archive / Unarchive ───────────────────────────────────────────────────
- if (!res.ok) { alert('Fehler beim Archivieren.'); return; }
+ async function archiveMember(row) {
+ try {
+ const res = await fetch(`/api/team/${row.dataset.id}/archive`, { method: 'PATCH' });
+ if (!res.ok) { alert(t('errorArchive')); return; }
- row.classList.add('crud-row--removing');
- setTimeout(() => window.location.reload(), 280);
+ removeWithAnimation(row, 'crud-row--removing');
+ setTimeout(() => window.location.reload(), ANIMATION_MS);
+ } catch {
+ alert(t('errorArchive'));
}
+ }
- async function unarchiveMember(row) {
- const id = row.dataset.id;
- const res = await fetch(`/api/team/${id}/unarchive`, { method: 'PATCH' });
-
- if (!res.ok) { alert('Fehler beim Wiederherstellen.'); return; }
+ async function unarchiveMember(row) {
+ try {
+ const res = await fetch(`/api/team/${row.dataset.id}/unarchive`, { method: 'PATCH' });
+ if (!res.ok) { alert(t('errorRestore')); return; }
- row.classList.add('crud-row--removing');
- setTimeout(() => window.location.reload(), 280);
+ removeWithAnimation(row, 'crud-row--removing');
+ setTimeout(() => window.location.reload(), ANIMATION_MS);
+ } catch {
+ alert(t('errorRestore'));
}
-});
\ No newline at end of file
+ }
+});
diff --git a/httpdocs/assets/scripts/utils.js b/httpdocs/assets/scripts/utils.js
new file mode 100644
index 0000000..b172f4d
--- /dev/null
+++ b/httpdocs/assets/scripts/utils.js
@@ -0,0 +1,26 @@
+// assets/scripts/utils.js
+
+const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
+
+export const ANIMATION_MS = 280;
+export const FADE_MS = 180;
+export const MINUTES_PER_DAY = 1440;
+
+export function esc(str) {
+ return String(str ?? '').replace(/[&<>"']/g, c => ESCAPES[c]);
+}
+
+export function createTranslator(namespace, defaults = {}) {
+ return (key) => window[namespace]?.i18n?.[key] ?? defaults[key] ?? key;
+}
+
+export function removeWithAnimation(el, className) {
+ el.classList.add(className);
+ setTimeout(() => el.remove(), ANIMATION_MS);
+}
+
+export function animateIn(el, className) {
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ el.classList.remove(className);
+ }));
+}
diff --git a/httpdocs/assets/styles/atoms/_inputs.scss b/httpdocs/assets/styles/atoms/_inputs.scss
index 2cdfd58..8287e96 100644
--- a/httpdocs/assets/styles/atoms/_inputs.scss
+++ b/httpdocs/assets/styles/atoms/_inputs.scss
@@ -52,7 +52,7 @@
}
}
-// ─── Select Label Tag (wie "Dogument", "Verrechenbar") ───────────────────────
+// ─── Select Label Tag (z.B. "Verrechenbar") ─────────────────────────────────
.select-hint {
font-size: $font-size-xs;
color: $color-text-muted;
diff --git a/httpdocs/assets/styles/atoms/_mixins.scss b/httpdocs/assets/styles/atoms/_mixins.scss
new file mode 100644
index 0000000..02e28f1
--- /dev/null
+++ b/httpdocs/assets/styles/atoms/_mixins.scss
@@ -0,0 +1,53 @@
+@use 'variables' as *;
+
+@mixin icon-btn($size: 28px, $shape: 50%) {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: $size;
+ height: $size;
+ border-radius: $shape;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ transition: opacity $transition-fast, background $transition-fast, color $transition-fast;
+
+ svg { pointer-events: none; }
+}
+
+@mixin card($bg: $color-card-white, $radius: $radius-lg) {
+ background: $bg;
+ border-radius: $radius;
+ box-shadow: $shadow-card;
+}
+
+@mixin page-shell {
+ min-height: 100vh;
+ background: var(--color-bg);
+ display: flex;
+ flex-direction: column;
+}
+
+@mixin section-header {
+ background: linear-gradient(135deg, var(--color-header-from) 0%, var(--color-header-to) 100%);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: $space-6;
+ box-shadow: $shadow-header;
+}
+
+@mixin text-truncate {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+@mixin form-label {
+ font-size: $font-size-sm;
+ color: $color-text-muted;
+ text-align: right;
+ padding-right: $space-2;
+ white-space: nowrap;
+}
+
diff --git a/httpdocs/assets/styles/atoms/_variables.scss b/httpdocs/assets/styles/atoms/_variables.scss
index d1ac98a..084a009 100644
--- a/httpdocs/assets/styles/atoms/_variables.scss
+++ b/httpdocs/assets/styles/atoms/_variables.scss
@@ -1,5 +1,4 @@
// ─── Color Palette ───────────────────────────────────────────────────────────
-// Compile-time values (used in rgba() functions; keep as hex)
$color-primary: #4a90d9;
$color-primary-dark: #3178b8;
$color-primary-light: #6aaee8;
@@ -12,20 +11,6 @@ $color-accent-light: #f5bc3a;
$color-white: #ffffff;
$color-bg: #dce9f5;
-
-// ─── CSS Custom Properties (runtime-overridable via brand color) ──────────────
-:root {
- --color-primary: #{$color-primary};
- --color-primary-dark: #{$color-primary-dark};
- --color-primary-light: #{$color-primary-light};
- --color-header-from: #{$color-header-from};
- --color-header-to: #{$color-header-to};
- --color-bg: #{$color-bg};
- --color-primary-rgb: 74, 144, 217;
- --header-text: #{$color-white};
- --header-text-muted: rgba(255, 255, 255, 0.75);
- --header-overlay: rgba(255, 255, 255, 0.18);
-}
$color-card: #f0f0f0;
$color-card-white: #ffffff;
@@ -40,21 +25,30 @@ $color-input-border: #b8c4d0;
$color-day-active-bg: #1a2a3a;
$color-day-active-text:#ffffff;
-$color-day-hover: rgba(255,255,255,0.2);
$color-error: #c83232;
-
$color-success: #2d9e60;
$color-success-bg: #e6f5ee;
-
$color-activate: #3a9a3a;
$color-activate-light: #4ab44a;
-
$color-warning: #b86200;
$color-warning-light: #e8820a;
-
$color-overlay: rgba(0, 0, 0, 0.45);
+// ─── CSS Custom Properties (runtime-overridable via brand color) ──────────────
+:root {
+ --color-primary: #{$color-primary};
+ --color-primary-dark: #{$color-primary-dark};
+ --color-primary-light: #{$color-primary-light};
+ --color-header-from: #{$color-header-from};
+ --color-header-to: #{$color-header-to};
+ --color-bg: #{$color-bg};
+ --color-primary-rgb: 74, 144, 217;
+ --header-text: #{$color-white};
+ --header-text-muted: rgba(255, 255, 255, 0.75);
+ --header-overlay: rgba(255, 255, 255, 0.18);
+}
+
// ─── Typography ──────────────────────────────────────────────────────────────
$font-family-base: 'DM Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
$font-size-xs: 0.7rem;
@@ -89,12 +83,12 @@ $radius-xl: 24px;
$radius-pill: 100px;
// ─── Shadows ─────────────────────────────────────────────────────────────────
-$shadow-card: 0 2px 12px rgba(0, 60, 120, 0.08);
-$shadow-header: 0 2px 16px rgba(0, 50, 120, 0.2);
-$shadow-calendar:0 8px 32px rgba(0, 60, 120, 0.35);
-$shadow-input: 0 1px 3px rgba(0, 40, 80, 0.06) inset;
-$shadow-focus: 0 0 0 3px rgba(#4a90d9, 0.15);
-$shadow-button: 0 2px 8px rgba(240, 165, 0, 0.35);
+$shadow-card: 0 2px 12px rgba(0, 60, 120, 0.08);
+$shadow-header: 0 2px 16px rgba(0, 50, 120, 0.2);
+$shadow-calendar: 0 8px 32px rgba(0, 60, 120, 0.35);
+$shadow-input: 0 1px 3px rgba(0, 40, 80, 0.06) inset;
+$shadow-focus: 0 0 0 3px rgba($color-primary, 0.15);
+$shadow-button: 0 2px 8px rgba($color-accent, 0.35);
// ─── Transitions ─────────────────────────────────────────────────────────────
$transition-fast: 0.15s ease;
@@ -102,5 +96,7 @@ $transition-base: 0.2s ease;
$transition-slow: 0.3s ease;
// ─── Layout ──────────────────────────────────────────────────────────────────
-$header-height: 88px;
+$header-height: 88px;
$content-max-width: 860px;
+$icon-btn-size: 28px;
+$icon-svg-size: 14px;
diff --git a/httpdocs/assets/styles/components/_account.scss b/httpdocs/assets/styles/components/_account.scss
index 575c780..6429eb9 100644
--- a/httpdocs/assets/styles/components/_account.scss
+++ b/httpdocs/assets/styles/components/_account.scss
@@ -1,22 +1,15 @@
@use '../atoms/variables' as *;
+@use '../atoms/mixins' as *;
// ─── Page ─────────────────────────────────────────────────────────────────────
.account-page {
- min-height: 100vh;
- background: var(--color-bg);
- display: flex;
- flex-direction: column;
+ @include page-shell;
}
// ─── Header ──────────────────────────────────────────────────────────────────
.account-header {
- background: linear-gradient(135deg, var(--color-header-from) 0%, var(--color-header-to) 100%);
+ @include section-header;
padding: $space-6;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: $space-6;
- box-shadow: $shadow-header;
}
.account-header__title {
@@ -74,9 +67,7 @@
// ─── Karte ───────────────────────────────────────────────────────────────────
.account-card {
- background: $color-card-white;
- border-radius: $radius-lg;
- box-shadow: $shadow-card;
+ @include card;
padding: $space-8;
}
@@ -89,9 +80,8 @@
}
.account-form__label {
- font-size: $font-size-sm;
+ @include form-label;
font-weight: $font-weight-medium;
- color: $color-text-muted;
padding-top: 7px;
}
@@ -146,11 +136,7 @@
// ─── Passwort-Sektion (toggle) ────────────────────────────────────────────────
.account-form__pw-section {
- display: contents; // bleibt im Grid-Fluss
-
- &[hidden] {
- display: none !important;
- }
+ display: contents;
}
// ─── Actions ─────────────────────────────────────────────────────────────────
diff --git a/httpdocs/assets/styles/components/_crud.scss b/httpdocs/assets/styles/components/_crud.scss
index 66e218b..881de00 100644
--- a/httpdocs/assets/styles/components/_crud.scss
+++ b/httpdocs/assets/styles/components/_crud.scss
@@ -1,4 +1,5 @@
@use '../atoms/variables' as *;
+@use '../atoms/mixins' as *;
// ─── CRUD Seiten Layout ────────────────────────────────────────────────────────
.crud-page {
@@ -22,9 +23,7 @@
// ─── Liste ─────────────────────────────────────────────────────────────────────
.crud-list {
- background: $color-card-white;
- border-radius: $radius-lg;
- box-shadow: $shadow-card;
+ @include card;
overflow: hidden;
}
@@ -86,20 +85,11 @@
}
.crud-row__btn {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 28px;
- height: 28px;
- border-radius: 50%;
- background: transparent;
- border: none;
- cursor: pointer;
+ @include icon-btn;
opacity: 0;
- transition: opacity $transition-fast, background $transition-fast, color $transition-fast;
color: $color-text-muted;
- svg { width: 14px; height: 14px; pointer-events: none; }
+ svg { width: $icon-svg-size; height: $icon-svg-size; }
&--edit:hover { background: rgba(var(--color-primary-rgb), 0.1); color: var(--color-primary); }
&--delete:hover{ background: rgba($color-error, 0.1); color: $color-error; }
@@ -114,15 +104,11 @@
border-top: 1px solid rgba($color-border, 0.5);
}
-.crud-row__display[hidden] { display: none !important; }
-
// ─── Create-Formular oben ──────────────────────────────────────────────────────
.crud-create {
- background: $color-card;
- border-radius: $radius-lg;
+ @include card($color-card);
padding: $space-5 $space-6;
margin-bottom: $space-4;
- box-shadow: $shadow-card;
display: none;
&--visible { display: block; }
@@ -131,11 +117,10 @@
// ─── Tabs (Aktiv / Archiviert) ─────────────────────────────────────────────────
.crud-tabs {
display: inline-flex;
- background: $color-card-white;
+ @include card;
border-radius: $radius-pill;
padding: 3px;
margin-bottom: $space-4;
- box-shadow: $shadow-card;
}
.crud-tab {
diff --git a/httpdocs/assets/styles/components/_entry-form.scss b/httpdocs/assets/styles/components/_entry-form.scss
index 04859b3..6e6e96c 100644
--- a/httpdocs/assets/styles/components/_entry-form.scss
+++ b/httpdocs/assets/styles/components/_entry-form.scss
@@ -1,11 +1,10 @@
@use '../atoms/variables' as *;
+@use '../atoms/mixins' as *;
// ─── Entry Form Card ─────────────────────────────────────────────────────────
.entry-form {
- background: $color-card;
- border-radius: $radius-lg;
+ @include card($color-card);
padding: $space-6 $space-8;
- box-shadow: $shadow-card;
}
.entry-form__grid {
@@ -30,6 +29,19 @@
gap: $space-2;
}
+.entry-form__field--rate {
+ gap: $space-2;
+}
+
+.entry-form__unit {
+ color: $color-text-muted;
+ font-size: $font-size-sm;
+}
+
+.input--rate {
+ width: 100px;
+}
+
.entry-form__field--selects {
display: flex;
gap: $space-3;
diff --git a/httpdocs/assets/styles/components/_entry-list.scss b/httpdocs/assets/styles/components/_entry-list.scss
index 4e0067e..3a332ee 100644
--- a/httpdocs/assets/styles/components/_entry-list.scss
+++ b/httpdocs/assets/styles/components/_entry-list.scss
@@ -1,10 +1,9 @@
@use '../atoms/variables' as *;
+@use '../atoms/mixins' as *;
// ─── Entry List Container ──────────────────────────────────────────────────
.entry-list {
- background: $color-card-white;
- border-radius: $radius-lg;
- box-shadow: $shadow-card;
+ @include card;
overflow: hidden;
transition: opacity 0.18s ease;
@@ -13,10 +12,8 @@
// ─── Empty State ──────────────────────────────────────────────────────────
.empty-state {
- background: $color-card-white;
- border-radius: $radius-lg;
+ @include card;
padding: $space-6 $space-8;
- box-shadow: $shadow-card;
}
.empty-state__title {
@@ -30,8 +27,7 @@
.entry-list__footer {
display: flex;
justify-content: flex-end;
- // 2 Buttons (28px) + 2× gap (8px) + eigener padding = Badge bündig
- padding: $space-3 calc(#{$space-8} + 28px + 28px + #{$space-2} + #{$space-2});
+ padding: $space-3 calc(#{$space-8} + #{$icon-btn-size} + #{$icon-btn-size} + #{$space-2} + #{$space-2});
border-top: 1px solid $color-border;
}
@@ -51,13 +47,11 @@
&:last-child { border-bottom: none; }
- // Fade-in bei neuem Eintrag
&--new {
opacity: 0;
transform: translateY(-6px);
}
- // Fade-out beim Löschen
&--removing {
opacity: 0;
transform: translateX(12px);
@@ -79,13 +73,7 @@
&:hover {
background: rgba(var(--color-primary-rgb), 0.05);
- .entry-row__btn {
- opacity: 1;
- }
- }
-
- &[hidden] {
- display: none !important;
+ .entry-row__btn { opacity: 1; }
}
}
@@ -98,18 +86,14 @@
font-size: $font-size-base;
font-weight: $font-weight-bold;
color: $color-text-dark;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
+ @include text-truncate;
}
.entry-row__note {
font-size: $font-size-sm;
color: $color-text-muted;
margin-top: 2px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
+ @include text-truncate;
}
.entry-row__actions {
@@ -132,25 +116,15 @@
}
.entry-row__btn {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 28px;
- height: 28px;
- border-radius: 50%;
- background: transparent;
- border: none;
- cursor: pointer;
+ @include icon-btn;
opacity: 0;
- transition: opacity $transition-fast, background $transition-fast, color $transition-fast;
color: $color-text-muted;
- svg { width: 14px; height: 14px; pointer-events: none; }
+ svg { width: $icon-svg-size; height: $icon-svg-size; }
&--edit:hover { background: rgba(var(--color-primary-rgb), 0.1); color: var(--color-primary); }
&--delete:hover{ background: rgba($color-error, 0.1); color: $color-error; }
- // immer sichtbar auf Touch-Geräten
@media (hover: none) { opacity: 1; }
}
@@ -159,11 +133,11 @@
display: flex;
align-items: center;
justify-content: center;
- width: calc(28px + #{$space-2} + 28px);
+ width: calc(#{$icon-btn-size} + #{$space-2} + #{$icon-btn-size});
flex-shrink: 0;
color: $color-text-dark;
- svg { width: 14px; height: 14px; pointer-events: none; }
+ svg { width: $icon-svg-size; height: $icon-svg-size; pointer-events: none; }
}
// ─── Abgerechneter Eintrag ────────────────────────────────────────────────
@@ -181,7 +155,6 @@
}
.entry-form__grid--inline {
- // Gleiche Grid-Struktur wie das Haupt-Formular
display: grid;
grid-template-columns: 130px 1fr;
gap: $space-3 $space-6;
diff --git a/httpdocs/assets/styles/components/_login.scss b/httpdocs/assets/styles/components/_login.scss
index d7bd0ea..deaa333 100644
--- a/httpdocs/assets/styles/components/_login.scss
+++ b/httpdocs/assets/styles/components/_login.scss
@@ -1,4 +1,5 @@
@use '../atoms/variables' as *;
+@use '../atoms/mixins' as *;
// ─── Login Page ───────────────────────────────────────────────────────────────
.login-body {
@@ -11,12 +12,10 @@
// ─── Card ─────────────────────────────────────────────────────────────────────
.login-card {
- background: $color-card-white;
- border-radius: $radius-xl;
+ @include card($color-card-white, $radius-xl);
padding: $space-10 $space-12;
width: 100%;
max-width: 540px;
- box-shadow: $shadow-card;
}
.login-card__title {
@@ -47,10 +46,8 @@
}
.login-form__label {
+ @include form-label;
font-size: $font-size-base;
- color: $color-text-muted;
- text-align: right;
- padding-right: $space-2;
}
.login-form__field {
@@ -76,7 +73,7 @@
}
}
-// ─── Footer-Link (z. B. "Zurück zur Anmeldung") ───────────────────────────────
+// ─── Footer-Link ──────────────────────────────────────────────────────────────
.login-form__footer {
text-align: center;
margin-top: $space-6;
@@ -141,4 +138,4 @@
.login-form__submit {
padding: $space-3 $space-10;
font-size: $font-size-md;
-}
\ No newline at end of file
+}
diff --git a/httpdocs/assets/styles/components/_month-calendar.scss b/httpdocs/assets/styles/components/_month-calendar.scss
index 7f74ff6..a743bcf 100644
--- a/httpdocs/assets/styles/components/_month-calendar.scss
+++ b/httpdocs/assets/styles/components/_month-calendar.scss
@@ -1,4 +1,5 @@
@use '../atoms/variables' as *;
+@use '../atoms/mixins' as *;
// ─── Monatskalender Container ─────────────────────────────────────────────────
.month-calendar {
@@ -44,17 +45,8 @@
}
.month-calendar__arrow {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 28px;
- height: 28px;
- border-radius: 50%;
- background: transparent;
- border: none;
+ @include icon-btn;
color: var(--header-text);
- cursor: pointer;
- transition: background $transition-fast;
&:hover { background: var(--header-overlay); }
@@ -62,7 +54,6 @@
}
.month-calendar__close {
- // erbt .week-nav__cal Styles – hier nur Positionierung
margin-left: 0;
}
@@ -112,13 +103,11 @@
background: var(--header-overlay);
}
- // Tage aus Vor-/Nachmonat
&--other {
color: var(--header-text-muted);
cursor: default;
}
- // Heutiger Tag
&--today {
font-weight: $font-weight-bold;
background: $color-white;
@@ -129,7 +118,6 @@
}
}
- // Ausgewählter Tag
&--active:not(&--today) {
background: var(--header-overlay);
font-weight: $font-weight-bold;
diff --git a/httpdocs/assets/styles/components/_register.scss b/httpdocs/assets/styles/components/_register.scss
index 7f06ec2..f11cda9 100644
--- a/httpdocs/assets/styles/components/_register.scss
+++ b/httpdocs/assets/styles/components/_register.scss
@@ -1,4 +1,5 @@
@use '../atoms/variables' as *;
+@use '../atoms/mixins' as *;
.register-body {
min-height: 100vh;
@@ -16,10 +17,8 @@
}
.register-card {
- background: $color-card-white;
- border-radius: $radius-xl;
+ @include card($color-card-white, $radius-xl);
padding: $space-10 $space-12;
- box-shadow: $shadow-card;
}
.register-card__brand {
@@ -186,8 +185,12 @@
margin-bottom: $space-3;
}
+.register-success__btn {
+ margin-top: $space-6;
+}
+
.register-success__hint {
font-size: $font-size-sm;
color: $color-text-muted;
line-height: $line-height-base;
-}
\ No newline at end of file
+}
diff --git a/httpdocs/assets/styles/components/_team.scss b/httpdocs/assets/styles/components/_team.scss
index 2564744..b39d90e 100644
--- a/httpdocs/assets/styles/components/_team.scss
+++ b/httpdocs/assets/styles/components/_team.scss
@@ -1,4 +1,5 @@
@use '../atoms/variables' as *;
+@use '../atoms/mixins' as *;
// ─── Ausstehend-Badge ──────────────────────────────────────────────────────────
.team-badge {
@@ -24,14 +25,10 @@
align-items: center;
justify-content: center;
z-index: 200;
-
- &[hidden] { display: none !important; }
}
.modal-card {
- background: $color-card-white;
- border-radius: $radius-lg;
- box-shadow: $shadow-card;
+ @include card;
width: 100%;
max-width: 460px;
padding: 0;
@@ -53,18 +50,11 @@
}
.modal-card__close {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 28px;
- height: 28px;
- background: transparent;
- border: none;
- cursor: pointer;
+ @include icon-btn;
color: $color-text-muted;
- border-radius: 50%;
- transition: background $transition-fast;
+
svg { width: 16px; height: 16px; }
+
&:hover { background: rgba($color-border, 0.5); }
}
@@ -111,8 +101,6 @@
color: $color-error;
font-size: $font-size-sm;
- &[hidden] { display: none !important; }
-
ul { margin: 0; padding-left: 1.2em; }
}
@@ -161,4 +149,4 @@
margin-top: $space-1;
font-size: $font-size-xs;
color: $color-text-muted;
-}
\ No newline at end of file
+}
diff --git a/httpdocs/assets/styles/components/_week-nav.scss b/httpdocs/assets/styles/components/_week-nav.scss
index 8778b64..c02307b 100644
--- a/httpdocs/assets/styles/components/_week-nav.scss
+++ b/httpdocs/assets/styles/components/_week-nav.scss
@@ -1,4 +1,5 @@
@use '../atoms/variables' as *;
+@use '../atoms/mixins' as *;
// ─── Wrapper ─────────────────────────────────────────────────────────────────
.week-nav {
@@ -14,19 +15,9 @@
// ─── Pfeile ──────────────────────────────────────────────────────────────────
.week-nav__arrow {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 28px;
- height: 28px;
- border-radius: 50%;
- background: transparent;
- border: none;
+ @include icon-btn;
color: var(--header-text);
- cursor: pointer;
text-decoration: none;
- flex-shrink: 0;
- transition: background $transition-fast;
&:hover { background: var(--header-overlay); }
@@ -101,21 +92,12 @@
// ─── Kalender-Icon ───────────────────────────────────────────────────────────
.week-nav__cal {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 34px;
- height: 34px;
- border-radius: $radius-md;
+ @include icon-btn(34px, $radius-md);
background: var(--header-overlay);
color: var(--header-text);
- cursor: pointer;
- border: none;
margin-left: $space-1;
- flex-shrink: 0;
- transition: background $transition-fast;
- svg { width: 16px; height: 16px; pointer-events: none; }
+ svg { width: 16px; height: 16px; }
&:hover,
&--active { background: var(--header-overlay); }
diff --git a/httpdocs/assets/styles/main.scss b/httpdocs/assets/styles/main.scss
index b0f6e43..b1de747 100644
--- a/httpdocs/assets/styles/main.scss
+++ b/httpdocs/assets/styles/main.scss
@@ -1,5 +1,6 @@
// ─── Atoms ────────────────────────────────────────────────────────────────────
@use 'atoms/variables' as *;
+@use 'atoms/mixins' as *;
@use 'atoms/typography';
@use 'atoms/buttons';
@use 'atoms/inputs';
@@ -27,6 +28,8 @@
@use 'themes/minimal';
// ─── Reset / Base ─────────────────────────────────────────────────────────────
+@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap');
+
*,
*::before,
*::after {
@@ -43,4 +46,6 @@ body {
background: var(--color-bg);
}
-@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap');
+[hidden] {
+ display: none !important;
+}
diff --git a/httpdocs/assets/styles/sections/_home.scss b/httpdocs/assets/styles/sections/_home.scss
index fdbf07d..413449b 100644
--- a/httpdocs/assets/styles/sections/_home.scss
+++ b/httpdocs/assets/styles/sections/_home.scss
@@ -1,10 +1,8 @@
@use '../atoms/variables' as *;
+@use '../atoms/mixins' as *;
.home-body {
- min-height: 100vh;
- background: var(--color-bg);
- display: flex;
- flex-direction: column;
+ @include page-shell;
}
// ─── Header ──────────────────────────────────────────────────────────────────
@@ -64,4 +62,4 @@
.home-hero__cta {
font-size: $font-size-md;
padding: $space-4 $space-10;
-}
\ No newline at end of file
+}
diff --git a/httpdocs/assets/styles/sections/_report.scss b/httpdocs/assets/styles/sections/_report.scss
index 4f2b3f9..d5e83a2 100644
--- a/httpdocs/assets/styles/sections/_report.scss
+++ b/httpdocs/assets/styles/sections/_report.scss
@@ -1,22 +1,15 @@
@use '../atoms/variables' as *;
+@use '../atoms/mixins' as *;
// ─── Page ─────────────────────────────────────────────────────────────────────
.report-page {
- min-height: 100vh;
- background: var(--color-bg);
- display: flex;
- flex-direction: column;
+ @include page-shell;
}
// ─── Header ──────────────────────────────────────────────────────────────────
.report-header {
- background: linear-gradient(135deg, var(--color-header-from) 0%, var(--color-header-to) 100%);
+ @include section-header;
padding: $space-4 $space-6;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: $space-6;
- box-shadow: $shadow-header;
}
.report-header__title {
@@ -72,9 +65,7 @@
// ─── Karte ───────────────────────────────────────────────────────────────────
.report-card {
- background: $color-card-white;
- border-radius: $radius-lg;
- box-shadow: $shadow-card;
+ @include card;
overflow: hidden;
}
@@ -93,6 +84,31 @@
gap: $space-6;
}
+.report-toolbar__right {
+ display: flex;
+ align-items: center;
+ gap: $space-2;
+}
+
+.report-toolbar__export {
+ @include icon-btn(30px, $radius-sm);
+ color: $color-text-light;
+
+ svg { width: 18px; height: 18px; }
+
+ &:hover {
+ color: var(--color-primary);
+ background: rgba(var(--color-primary-rgb), 0.08);
+ }
+}
+
+.report-toolbar__separator {
+ width: 1px;
+ height: 18px;
+ background: $color-border;
+ margin: 0 $space-1;
+}
+
.report-toolbar__action {
display: inline-flex;
align-items: center;
@@ -104,8 +120,8 @@
text-decoration: none;
svg {
- width: 14px;
- height: 14px;
+ width: $icon-svg-size;
+ height: $icon-svg-size;
flex-shrink: 0;
}
@@ -133,7 +149,7 @@
1fr // Bemerkung
80px // Stunden
100px // Umsatz
- 88px; // Aktionen (Edit + Delete + Schloss)
+ 88px; // Aktionen
align-items: center;
border-bottom: 1px solid $color-border;
padding: 0 $space-5;
@@ -142,7 +158,6 @@
.report-table__head {
padding-top: $space-2;
padding-bottom: $space-2;
- background: transparent;
.report-table__cell {
font-size: $font-size-xs;
@@ -161,34 +176,24 @@
&:hover {
background: rgba(var(--color-primary-rgb), 0.05);
- }
- &:hover .report-action-btn {
- opacity: 1;
+ .report-action-btn { opacity: 1; }
}
- &--invoiced {
- .report-table__cell--date { color: $color-text-light; }
- .report-table__cell--client { color: $color-text-light; }
- .report-table__cell--project { color: $color-text-light; }
- .report-table__cell--service { color: $color-text-light; }
- .report-table__cell--user { color: $color-text-light; }
- .report-table__cell--note { color: $color-text-light; }
- .report-table__cell--duration { color: $color-text-light; }
- .report-table__cell--revenue { color: $color-text-light; }
+ &--invoiced .report-table__cell {
+ &--date, &--client, &--project, &--service,
+ &--user, &--note, &--duration, &--revenue {
+ color: $color-text-light;
+ }
}
&--editing {
background: rgba(var(--color-primary-rgb), 0.05);
- .report-table__cell--actions {
- visibility: hidden;
- }
+ .report-table__cell--actions { visibility: hidden; }
}
- &:last-child {
- border-bottom: none;
- }
+ &:last-child { border-bottom: none; }
}
.report-table__cell {
@@ -217,9 +222,7 @@
&--note {
color: $color-text-muted;
font-size: $font-size-sm;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
+ @include text-truncate;
}
}
@@ -245,23 +248,11 @@
// ─── Aktions-Buttons (Edit / Delete) ─────────────────────────────────────────
.report-action-btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 26px;
- height: 26px;
- border: none;
- background: none;
- cursor: pointer;
- color: $color-text-light;
- border-radius: $radius-sm;
+ @include icon-btn(26px, $radius-sm);
opacity: 0;
- transition: opacity $transition-fast, color $transition-fast, background $transition-fast;
+ color: $color-text-light;
- svg {
- width: 14px;
- height: 14px;
- }
+ svg { width: $icon-svg-size; height: $icon-svg-size; }
&:hover {
color: $color-text-muted;
@@ -276,22 +267,10 @@
// ─── Schloss-Button ──────────────────────────────────────────────────────────
.report-lock {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 24px;
- height: 24px;
- border: none;
- background: none;
- cursor: pointer;
+ @include icon-btn(24px, $radius-sm);
color: $color-text-light;
- border-radius: $radius-sm;
- transition: color $transition-fast, background $transition-fast;
- svg {
- width: 14px;
- height: 14px;
- }
+ svg { width: $icon-svg-size; height: $icon-svg-size; }
&:hover {
color: $color-text-muted;
@@ -325,11 +304,7 @@
}
.report-row__edit-label {
- font-size: $font-size-sm;
- color: $color-text-muted;
- text-align: right;
- padding-right: $space-2;
- white-space: nowrap;
+ @include form-label;
}
.report-row__edit-field {
@@ -379,9 +354,7 @@
text-decoration: underline;
cursor: pointer;
- &:hover {
- color: var(--color-primary-dark);
- }
+ &:hover { color: var(--color-primary-dark); }
}
strong {
@@ -401,7 +374,7 @@
}
.report-pagination__lock-spacer {
- // Platzhalter für die Aktions-Spalte – hält die Ausrichtung
+ // Platzhalter – hält Spalten-Ausrichtung mit der Tabelle
}
// ─── Toolbar-Button (klickbar) ────────────────────────────────────────────────
@@ -462,29 +435,28 @@ button.report-toolbar__action {
padding: $space-2 0;
border-bottom: 1px solid rgba($color-border, 0.6);
- &:last-child {
- border-bottom: none;
- }
+ &:last-child { border-bottom: none; }
- // Ausgegraut wenn inaktiv – aber klickbar!
&--inactive {
.filter-select,
.filter-note-input,
- .filter-period-select {
+ .filter-period-select,
+ .filter-radio {
opacity: 0.5;
- color: $color-text-muted;
}
- .filter-row__add {
- opacity: 0.4;
+ .filter-select,
+ .filter-note-input,
+ .filter-period-select {
+ color: $color-text-muted;
}
- .filter-radio {
- opacity: 0.5;
+ .filter-row__add,
+ .filter-neg {
+ opacity: 0.4;
}
.filter-neg {
- opacity: 0.4;
pointer-events: none;
}
}
@@ -497,7 +469,7 @@ button.report-toolbar__action {
cursor: pointer;
font-size: $font-size-sm;
color: $color-text-base;
- padding-top: 7px; // optisch mit den Selects ausrichten
+ padding-top: 7px;
user-select: none;
}
@@ -552,31 +524,23 @@ button.report-toolbar__action {
display: flex;
align-items: center;
gap: $space-3;
- padding-top: 7px; // vertikal mit Select ausrichten
+ padding-top: 7px;
flex-shrink: 0;
white-space: nowrap;
&--no-add {
- padding-left: calc(22px + #{$space-3}); // Platz für fehlenden Add-Button
+ padding-left: calc(22px + #{$space-3});
}
}
// ─── Plus- und Minus-Button ───────────────────────────────────────────────────
.filter-row__add {
- width: 22px;
- height: 22px;
+ @include icon-btn(22px, $radius-sm);
border: 1px solid $color-input-border;
background: $color-white;
- border-radius: $radius-sm;
- cursor: pointer;
font-size: $font-size-md;
line-height: 1;
color: $color-text-muted;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- transition: border-color $transition-fast, color $transition-fast;
&:hover {
border-color: var(--color-primary);
@@ -585,20 +549,10 @@ button.report-toolbar__action {
}
.filter-row__remove {
- width: 20px;
- height: 20px;
- border: none;
- background: none;
- cursor: pointer;
+ @include icon-btn(20px, $radius-sm);
font-size: $font-size-md;
line-height: 1;
color: $color-text-light;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: $radius-sm;
- flex-shrink: 0;
- transition: color $transition-fast, background $transition-fast;
&:hover {
color: $color-error;
@@ -612,11 +566,6 @@ button.report-toolbar__action {
flex-direction: column;
gap: $space-2;
margin-top: $space-2;
-
- // [hidden] wird durch display:flex überschrieben – explizit gegensteuern
- &[hidden] {
- display: none;
- }
}
.filter-date-group {
@@ -679,9 +628,7 @@ button.report-toolbar__action {
text-underline-offset: 2px;
transition: color $transition-fast;
- &:hover {
- color: $color-text-base;
- }
+ &:hover { color: $color-text-base; }
}
// ─── Negativfilter-Checkbox ───────────────────────────────────────────────────
diff --git a/httpdocs/assets/styles/sections/_timetracking.scss b/httpdocs/assets/styles/sections/_timetracking.scss
index 1d6ac23..e6e99a8 100644
--- a/httpdocs/assets/styles/sections/_timetracking.scss
+++ b/httpdocs/assets/styles/sections/_timetracking.scss
@@ -1,26 +1,19 @@
@use '../atoms/variables' as *;
+@use '../atoms/mixins' as *;
// ─── Page Wrapper ─────────────────────────────────────────────────────────────
.tt-page {
- min-height: 100vh;
- background: var(--color-bg);
- display: flex;
- flex-direction: column;
+ @include page-shell;
}
// ─── Header Section ──────────────────────────────────────────────────────────
.tt-header {
- background: linear-gradient(135deg, var(--color-header-from) 0%, var(--color-header-to) 100%);
+ @include section-header;
padding: $space-4 $space-6;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: $space-6;
min-height: $header-height;
position: sticky;
top: 0;
z-index: 100;
- box-shadow: $shadow-header;
}
.tt-header__meta {
@@ -47,7 +40,7 @@
max-width: $content-max-width;
width: 100%;
margin: 0 auto;
- padding: $space-6 $space-6;
+ padding: $space-6;
display: flex;
flex-direction: column;
gap: $space-4;
diff --git a/httpdocs/assets/styles/themes/_minimal.scss b/httpdocs/assets/styles/themes/_minimal.scss
index 916cd39..782251a 100644
--- a/httpdocs/assets/styles/themes/_minimal.scss
+++ b/httpdocs/assets/styles/themes/_minimal.scss
@@ -1,11 +1,12 @@
@use '../atoms/variables' as *;
+@use '../atoms/mixins' as *;
// ─── Minimal Theme ─────────────────────────────────────────────────────────────
// Gilt nur wenn body[data-theme="minimal"] gesetzt ist.
// Standard-Theme bleibt vollständig unverändert.
body[data-theme="minimal"] {
- background: #fff;
+ background: $color-white;
// ── Normale Top-Nav ausblenden ──────────────────────────────────────────────
.main-nav { display: none; }
@@ -18,11 +19,11 @@ body[data-theme="minimal"] {
}
// ── Page-Background weiß ───────────────────────────────────────────────────
- .tt-page { background: #fff; }
+ .tt-page { background: $color-white; }
// ── TT-Header: kein Gradient, kein Schatten, cleaner Rahmen ───────────────
.tt-header {
- background: #fff;
+ background: $color-white;
box-shadow: none;
border-bottom: 1px solid $color-border;
padding: $space-3 $space-5;
@@ -85,7 +86,7 @@ body[data-theme="minimal"] {
// ── Entry Form: cleaner, größere Inputs ────────────────────────────────────
.entry-form {
- background: #fff;
+ background: $color-white;
border: none;
border-radius: 0;
padding: $space-4 0;
@@ -153,7 +154,7 @@ body[data-theme="minimal"] {
}
.entry-list {
- background: #fff;
+ background: $color-white;
box-shadow: none;
border: 1px solid $color-border;
border-radius: $radius-md;
@@ -164,7 +165,7 @@ body[data-theme="minimal"] {
.crud-page,
.account-page,
.team-page {
- background: #fff;
+ background: $color-white;
}
}
@@ -180,7 +181,7 @@ body[data-theme="minimal"] {
height: 52px;
border: none;
border-radius: $radius-lg;
- background: #fff;
+ background: $color-white;
cursor: pointer;
display: flex;
align-items: center;
@@ -230,7 +231,7 @@ body[data-theme="minimal"] {
top: calc(100% + #{$space-2});
right: 0;
min-width: 200px;
- background: #fff;
+ background: $color-white;
border: 1px solid $color-border;
border-radius: $radius-md;
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
@@ -303,7 +304,7 @@ body[data-theme="minimal"] {
align-items: center;
gap: $space-2;
padding: $space-3 $space-4;
- background: #fff;
+ background: $color-white;
border: 1px solid $color-border;
border-radius: $radius-md;
font-size: $font-size-base;
diff --git a/httpdocs/composer.json b/httpdocs/composer.json
index 35c97cb..cf89f09 100644
--- a/httpdocs/composer.json
+++ b/httpdocs/composer.json
@@ -12,6 +12,8 @@
"doctrine/doctrine-bundle": "^3.2.2",
"doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6.6",
+ "dompdf/dompdf": "^3.1",
+ "phpoffice/phpspreadsheet": "^5.8",
"symfony/console": "7.4.*",
"symfony/dotenv": "7.4.*",
"symfony/flex": "^2.10",
diff --git a/httpdocs/composer.lock b/httpdocs/composer.lock
index e32a8ff..1b387d4 100644
--- a/httpdocs/composer.lock
+++ b/httpdocs/composer.lock
@@ -4,8 +4,84 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "dae707f4e483331f467dcf211922216c",
+ "content-hash": "6a52005068f345beb15a732e99cbb73a",
"packages": [
+ {
+ "name": "composer/pcre",
+ "version": "3.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/composer/pcre.git",
+ "reference": "d5a341b3fb61f3001970940afb1d332968a183ed"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed",
+ "reference": "d5a341b3fb61f3001970940afb1d332968a183ed",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 || ^8.0"
+ },
+ "conflict": {
+ "phpstan/phpstan": "<2.2.2"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^2",
+ "phpstan/phpstan-deprecation-rules": "^2",
+ "phpstan/phpstan-strict-rules": "^2",
+ "phpunit/phpunit": "^9"
+ },
+ "type": "library",
+ "extra": {
+ "phpstan": {
+ "includes": [
+ "extension.neon"
+ ]
+ },
+ "branch-alias": {
+ "dev-main": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Composer\\Pcre\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jordi Boggiano",
+ "email": "j.boggiano@seld.be",
+ "homepage": "http://seld.be"
+ }
+ ],
+ "description": "PCRE wrapping library that offers type-safe preg_* replacements.",
+ "keywords": [
+ "PCRE",
+ "preg",
+ "regex",
+ "regular expression"
+ ],
+ "support": {
+ "issues": "https://github.com/composer/pcre/issues",
+ "source": "https://github.com/composer/pcre/tree/3.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://packagist.com",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/composer",
+ "type": "github"
+ }
+ ],
+ "time": "2026-06-07T11:47:49+00:00"
+ },
{
"name": "doctrine/collections",
"version": "2.6.0",
@@ -1120,6 +1196,161 @@
},
"time": "2026-02-08T16:21:46+00:00"
},
+ {
+ "name": "dompdf/dompdf",
+ "version": "v3.1.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/dompdf/dompdf.git",
+ "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
+ "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
+ "shasum": ""
+ },
+ "require": {
+ "dompdf/php-font-lib": "^1.0.0",
+ "dompdf/php-svg-lib": "^1.0.0",
+ "ext-dom": "*",
+ "ext-mbstring": "*",
+ "masterminds/html5": "^2.0",
+ "php": "^7.1 || ^8.0"
+ },
+ "require-dev": {
+ "ext-gd": "*",
+ "ext-json": "*",
+ "ext-zip": "*",
+ "mockery/mockery": "^1.3",
+ "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
+ "squizlabs/php_codesniffer": "^3.5",
+ "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
+ },
+ "suggest": {
+ "ext-gd": "Needed to process images",
+ "ext-gmagick": "Improves image processing performance",
+ "ext-imagick": "Improves image processing performance",
+ "ext-zlib": "Needed for pdf stream compression"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Dompdf\\": "src/"
+ },
+ "classmap": [
+ "lib/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-2.1"
+ ],
+ "authors": [
+ {
+ "name": "The Dompdf Community",
+ "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
+ }
+ ],
+ "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
+ "homepage": "https://github.com/dompdf/dompdf",
+ "support": {
+ "issues": "https://github.com/dompdf/dompdf/issues",
+ "source": "https://github.com/dompdf/dompdf/tree/v3.1.5"
+ },
+ "time": "2026-03-03T13:54:37+00:00"
+ },
+ {
+ "name": "dompdf/php-font-lib",
+ "version": "1.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/dompdf/php-font-lib.git",
+ "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
+ "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": "^7.1 || ^8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "FontLib\\": "src/FontLib"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-2.1-or-later"
+ ],
+ "authors": [
+ {
+ "name": "The FontLib Community",
+ "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
+ }
+ ],
+ "description": "A library to read, parse, export and make subsets of different types of font files.",
+ "homepage": "https://github.com/dompdf/php-font-lib",
+ "support": {
+ "issues": "https://github.com/dompdf/php-font-lib/issues",
+ "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
+ },
+ "time": "2026-01-20T14:10:26+00:00"
+ },
+ {
+ "name": "dompdf/php-svg-lib",
+ "version": "1.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/dompdf/php-svg-lib.git",
+ "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
+ "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": "^7.1 || ^8.0",
+ "sabberworm/php-css-parser": "^8.4 || ^9.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Svg\\": "src/Svg"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-3.0-or-later"
+ ],
+ "authors": [
+ {
+ "name": "The SvgLib Community",
+ "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
+ }
+ ],
+ "description": "A library to read, parse and export to PDF SVG files.",
+ "homepage": "https://github.com/dompdf/php-svg-lib",
+ "support": {
+ "issues": "https://github.com/dompdf/php-svg-lib/issues",
+ "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
+ },
+ "time": "2026-01-02T16:01:13+00:00"
+ },
{
"name": "egulias/email-validator",
"version": "4.0.4",
@@ -1187,6 +1418,258 @@
],
"time": "2025-03-06T22:45:56+00:00"
},
+ {
+ "name": "maennchen/zipstream-php",
+ "version": "3.2.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/maennchen/ZipStream-PHP.git",
+ "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
+ "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "ext-zlib": "*",
+ "php-64bit": "^8.3"
+ },
+ "require-dev": {
+ "brianium/paratest": "^7.7",
+ "ext-zip": "*",
+ "friendsofphp/php-cs-fixer": "^3.86",
+ "guzzlehttp/guzzle": "^7.5",
+ "mikey179/vfsstream": "^1.6",
+ "php-coveralls/php-coveralls": "^2.5",
+ "phpunit/phpunit": "^12.0",
+ "vimeo/psalm": "^6.0"
+ },
+ "suggest": {
+ "guzzlehttp/psr7": "^2.4",
+ "psr/http-message": "^2.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "ZipStream\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Paul Duncan",
+ "email": "pabs@pablotron.org"
+ },
+ {
+ "name": "Jonatan Männchen",
+ "email": "jonatan@maennchen.ch"
+ },
+ {
+ "name": "Jesse Donat",
+ "email": "donatj@gmail.com"
+ },
+ {
+ "name": "András Kolesár",
+ "email": "kolesar@kolesar.hu"
+ }
+ ],
+ "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
+ "keywords": [
+ "stream",
+ "zip"
+ ],
+ "support": {
+ "issues": "https://github.com/maennchen/ZipStream-PHP/issues",
+ "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/maennchen",
+ "type": "github"
+ }
+ ],
+ "time": "2026-04-11T18:38:28+00:00"
+ },
+ {
+ "name": "markbaker/complex",
+ "version": "3.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/MarkBaker/PHPComplex.git",
+ "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
+ "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "dev-master",
+ "phpcompatibility/php-compatibility": "^9.3",
+ "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
+ "squizlabs/php_codesniffer": "^3.7"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Complex\\": "classes/src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mark Baker",
+ "email": "mark@lange.demon.co.uk"
+ }
+ ],
+ "description": "PHP Class for working with complex numbers",
+ "homepage": "https://github.com/MarkBaker/PHPComplex",
+ "keywords": [
+ "complex",
+ "mathematics"
+ ],
+ "support": {
+ "issues": "https://github.com/MarkBaker/PHPComplex/issues",
+ "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
+ },
+ "time": "2022-12-06T16:21:08+00:00"
+ },
+ {
+ "name": "markbaker/matrix",
+ "version": "3.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/MarkBaker/PHPMatrix.git",
+ "reference": "728434227fe21be27ff6d86621a1b13107a2562c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
+ "reference": "728434227fe21be27ff6d86621a1b13107a2562c",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "dev-master",
+ "phpcompatibility/php-compatibility": "^9.3",
+ "phpdocumentor/phpdocumentor": "2.*",
+ "phploc/phploc": "^4.0",
+ "phpmd/phpmd": "2.*",
+ "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
+ "sebastian/phpcpd": "^4.0",
+ "squizlabs/php_codesniffer": "^3.7"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Matrix\\": "classes/src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mark Baker",
+ "email": "mark@demon-angel.eu"
+ }
+ ],
+ "description": "PHP Class for working with matrices",
+ "homepage": "https://github.com/MarkBaker/PHPMatrix",
+ "keywords": [
+ "mathematics",
+ "matrix",
+ "vector"
+ ],
+ "support": {
+ "issues": "https://github.com/MarkBaker/PHPMatrix/issues",
+ "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
+ },
+ "time": "2022-12-02T22:17:43+00:00"
+ },
+ {
+ "name": "masterminds/html5",
+ "version": "2.10.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Masterminds/html5-php.git",
+ "reference": "fcf91eb64359852f00d921887b219479b4f21251"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251",
+ "reference": "fcf91eb64359852f00d921887b219479b4f21251",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "php": ">=5.3.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.7-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Masterminds\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Matt Butcher",
+ "email": "technosophos@gmail.com"
+ },
+ {
+ "name": "Matt Farina",
+ "email": "matt@mattfarina.com"
+ },
+ {
+ "name": "Asmir Mustafic",
+ "email": "goetas@gmail.com"
+ }
+ ],
+ "description": "An HTML5 parser and serializer.",
+ "homepage": "http://masterminds.github.io/html5-php",
+ "keywords": [
+ "HTML5",
+ "dom",
+ "html",
+ "parser",
+ "querypath",
+ "serializer",
+ "xml"
+ ],
+ "support": {
+ "issues": "https://github.com/Masterminds/html5-php/issues",
+ "source": "https://github.com/Masterminds/html5-php/tree/2.10.0"
+ },
+ "time": "2025-07-25T09:04:22+00:00"
+ },
{
"name": "monolog/monolog",
"version": "3.10.0",
@@ -1290,6 +1773,115 @@
],
"time": "2026-01-02T08:56:05+00:00"
},
+ {
+ "name": "phpoffice/phpspreadsheet",
+ "version": "5.8.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
+ "reference": "01964d92536edf1a3a874b9580a52824bebf6fbb"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/01964d92536edf1a3a874b9580a52824bebf6fbb",
+ "reference": "01964d92536edf1a3a874b9580a52824bebf6fbb",
+ "shasum": ""
+ },
+ "require": {
+ "composer/pcre": "^1||^2||^3",
+ "ext-ctype": "*",
+ "ext-dom": "*",
+ "ext-fileinfo": "*",
+ "ext-filter": "*",
+ "ext-gd": "*",
+ "ext-iconv": "*",
+ "ext-libxml": "*",
+ "ext-mbstring": "*",
+ "ext-simplexml": "*",
+ "ext-xml": "*",
+ "ext-xmlreader": "*",
+ "ext-xmlwriter": "*",
+ "ext-zip": "*",
+ "ext-zlib": "*",
+ "maennchen/zipstream-php": "^2.1 || ^3.0",
+ "markbaker/complex": "^3.0",
+ "markbaker/matrix": "^3.0",
+ "php": "^8.1",
+ "psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "dev-main",
+ "dompdf/dompdf": "^2.0 || ^3.0",
+ "ext-intl": "*",
+ "friendsofphp/php-cs-fixer": "^3.2",
+ "mitoteam/jpgraph": "^10.5",
+ "mpdf/mpdf": "^8.1.1",
+ "phpcompatibility/php-compatibility": "^9.3",
+ "phpstan/phpstan": "^1.1 || ^2.0",
+ "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
+ "phpstan/phpstan-phpunit": "^1.0 || ^2.0",
+ "phpunit/phpunit": "^10.5",
+ "squizlabs/php_codesniffer": "^3.7",
+ "tecnickcom/tcpdf": "^6.5"
+ },
+ "suggest": {
+ "dompdf/dompdf": "Option for rendering PDF with PDF Writer",
+ "ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
+ "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
+ "mpdf/mpdf": "Option for rendering PDF with PDF Writer",
+ "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Maarten Balliauw",
+ "homepage": "https://blog.maartenballiauw.be"
+ },
+ {
+ "name": "Mark Baker",
+ "homepage": "https://markbakeruk.net"
+ },
+ {
+ "name": "Franck Lefevre",
+ "homepage": "https://rootslabs.net"
+ },
+ {
+ "name": "Erik Tilt"
+ },
+ {
+ "name": "Adrien Crivelli"
+ },
+ {
+ "name": "Owen Leibman"
+ }
+ ],
+ "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
+ "homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
+ "keywords": [
+ "OpenXML",
+ "excel",
+ "gnumeric",
+ "ods",
+ "php",
+ "spreadsheet",
+ "xls",
+ "xlsx"
+ ],
+ "support": {
+ "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
+ "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.8.0"
+ },
+ "time": "2026-06-07T03:51:10+00:00"
+ },
{
"name": "psr/cache",
"version": "3.0.0",
@@ -1540,6 +2132,137 @@
},
"time": "2024-09-11T13:17:53+00:00"
},
+ {
+ "name": "psr/simple-cache",
+ "version": "3.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/simple-cache.git",
+ "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
+ "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.0.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\SimpleCache\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interfaces for simple caching",
+ "keywords": [
+ "cache",
+ "caching",
+ "psr",
+ "psr-16",
+ "simple-cache"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
+ },
+ "time": "2021-10-29T13:26:27+00:00"
+ },
+ {
+ "name": "sabberworm/php-css-parser",
+ "version": "v9.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
+ "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949",
+ "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949",
+ "shasum": ""
+ },
+ "require": {
+ "ext-iconv": "*",
+ "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
+ "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
+ },
+ "require-dev": {
+ "php-parallel-lint/php-parallel-lint": "1.4.0",
+ "phpstan/extension-installer": "1.4.3",
+ "phpstan/phpstan": "1.12.32 || 2.1.32",
+ "phpstan/phpstan-phpunit": "1.4.2 || 2.0.8",
+ "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7",
+ "phpunit/phpunit": "8.5.52",
+ "rawr/phpunit-data-provider": "3.3.1",
+ "rector/rector": "1.2.10 || 2.2.8",
+ "rector/type-perfect": "1.0.0 || 2.1.0",
+ "squizlabs/php_codesniffer": "4.0.1",
+ "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1"
+ },
+ "suggest": {
+ "ext-mbstring": "for parsing UTF-8 CSS"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "9.4.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/Rule/Rule.php",
+ "src/RuleSet/RuleContainer.php"
+ ],
+ "psr-4": {
+ "Sabberworm\\CSS\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Raphael Schweikert"
+ },
+ {
+ "name": "Oliver Klee",
+ "email": "github@oliverklee.de"
+ },
+ {
+ "name": "Jake Hotson",
+ "email": "jake.github@qzdesign.co.uk"
+ }
+ ],
+ "description": "Parser for CSS Files written in PHP",
+ "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
+ "keywords": [
+ "css",
+ "parser",
+ "stylesheet"
+ ],
+ "support": {
+ "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
+ "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0"
+ },
+ "time": "2026-03-03T17:31:43+00:00"
+ },
{
"name": "symfony/asset",
"version": "v7.4.8",
@@ -6314,6 +7037,149 @@
],
"time": "2026-05-20T07:20:23+00:00"
},
+ {
+ "name": "thecodingmachine/safe",
+ "version": "v3.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thecodingmachine/safe.git",
+ "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
+ "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "php-parallel-lint/php-parallel-lint": "^1.4",
+ "phpstan/phpstan": "^2",
+ "phpunit/phpunit": "^10",
+ "squizlabs/php_codesniffer": "^3.2"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "lib/special_cases.php",
+ "generated/apache.php",
+ "generated/apcu.php",
+ "generated/array.php",
+ "generated/bzip2.php",
+ "generated/calendar.php",
+ "generated/classobj.php",
+ "generated/com.php",
+ "generated/cubrid.php",
+ "generated/curl.php",
+ "generated/datetime.php",
+ "generated/dir.php",
+ "generated/eio.php",
+ "generated/errorfunc.php",
+ "generated/exec.php",
+ "generated/fileinfo.php",
+ "generated/filesystem.php",
+ "generated/filter.php",
+ "generated/fpm.php",
+ "generated/ftp.php",
+ "generated/funchand.php",
+ "generated/gettext.php",
+ "generated/gmp.php",
+ "generated/gnupg.php",
+ "generated/hash.php",
+ "generated/ibase.php",
+ "generated/ibmDb2.php",
+ "generated/iconv.php",
+ "generated/image.php",
+ "generated/imap.php",
+ "generated/info.php",
+ "generated/inotify.php",
+ "generated/json.php",
+ "generated/ldap.php",
+ "generated/libxml.php",
+ "generated/lzf.php",
+ "generated/mailparse.php",
+ "generated/mbstring.php",
+ "generated/misc.php",
+ "generated/mysql.php",
+ "generated/mysqli.php",
+ "generated/network.php",
+ "generated/oci8.php",
+ "generated/opcache.php",
+ "generated/openssl.php",
+ "generated/outcontrol.php",
+ "generated/pcntl.php",
+ "generated/pcre.php",
+ "generated/pgsql.php",
+ "generated/posix.php",
+ "generated/ps.php",
+ "generated/pspell.php",
+ "generated/readline.php",
+ "generated/rnp.php",
+ "generated/rpminfo.php",
+ "generated/rrd.php",
+ "generated/sem.php",
+ "generated/session.php",
+ "generated/shmop.php",
+ "generated/sockets.php",
+ "generated/sodium.php",
+ "generated/solr.php",
+ "generated/spl.php",
+ "generated/sqlsrv.php",
+ "generated/ssdeep.php",
+ "generated/ssh2.php",
+ "generated/stream.php",
+ "generated/strings.php",
+ "generated/swoole.php",
+ "generated/uodbc.php",
+ "generated/uopz.php",
+ "generated/url.php",
+ "generated/var.php",
+ "generated/xdiff.php",
+ "generated/xml.php",
+ "generated/xmlrpc.php",
+ "generated/yaml.php",
+ "generated/yaz.php",
+ "generated/zip.php",
+ "generated/zlib.php"
+ ],
+ "classmap": [
+ "lib/DateTime.php",
+ "lib/DateTimeImmutable.php",
+ "lib/Exceptions/",
+ "generated/Exceptions/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "PHP core functions that throw exceptions instead of returning FALSE on error",
+ "support": {
+ "issues": "https://github.com/thecodingmachine/safe/issues",
+ "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/OskarStark",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/shish",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/silasjoisten",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/staabm",
+ "type": "github"
+ }
+ ],
+ "time": "2026-02-04T18:08:13+00:00"
+ },
{
"name": "twig/twig",
"version": "v3.26.0",
diff --git a/httpdocs/config/packages/doctrine.yaml b/httpdocs/config/packages/doctrine.yaml
index 3b37d14..1e63fa9 100644
--- a/httpdocs/config/packages/doctrine.yaml
+++ b/httpdocs/config/packages/doctrine.yaml
@@ -10,8 +10,7 @@ doctrine:
# ersetzt dbname zur Laufzeit mit 'db_{slug}'
url: '%env(resolve:DATABASE_URL)%'
profiling_collect_backtrace: '%kernel.debug%'
- # middlewares:
- # - App\Doctrine\TenantConnectionMiddleware
+ # Middleware wird via Service-Tag registriert (services.yaml)
orm:
default_entity_manager: central
diff --git a/httpdocs/config/services.yaml b/httpdocs/config/services.yaml
index ff2569b..0a8b8ff 100644
--- a/httpdocs/config/services.yaml
+++ b/httpdocs/config/services.yaml
@@ -65,5 +65,9 @@ services:
arguments:
$appDomain: '%app.domain%'
+ App\Doctrine\TenantConnectionMiddleware:
+ tags:
+ - { name: doctrine.middleware, connection: tenant }
+
App\Controller\InviteController:
arguments: ~
diff --git a/httpdocs/src/Controller/AccountController.php b/httpdocs/src/Controller/AccountController.php
index 29ab3c3..7bfdb83 100644
--- a/httpdocs/src/Controller/AccountController.php
+++ b/httpdocs/src/Controller/AccountController.php
@@ -14,6 +14,7 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Contracts\Translation\TranslatorInterface;
class AccountController extends AbstractController
{
@@ -24,6 +25,7 @@ class AccountController extends AbstractController
private readonly UserRepository $userRepo,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly BrandColorService $brandColorService,
+ private readonly TranslatorInterface $translator,
) {}
#[Route('/account', name: 'account_index')]
@@ -56,10 +58,10 @@ class AccountController extends AbstractController
'adminUsers' => $adminUsers,
'superAdminUserId' => $account->getSuperAdminUser()?->getId(),
'intervalOptions' => [
- 1 => 'Minuten',
- 15 => 'Viertelstunde',
- 30 => 'Halbe Stunde',
- 60 => 'Stunde',
+ 1 => $this->translator->trans('app.account.interval_minutes'),
+ 15 => $this->translator->trans('app.account.interval_quarter'),
+ 30 => $this->translator->trans('app.account.interval_half'),
+ 60 => $this->translator->trans('app.account.interval_hour'),
],
]);
}
@@ -72,7 +74,7 @@ class AccountController extends AbstractController
$accountUser = $this->accountUserRepo->findOneBy(['account' => $account, 'user' => $user]);
if (!$accountUser?->isAdmin()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$data = json_decode($request->getContent(), true) ?? [];
@@ -91,7 +93,7 @@ class AccountController extends AbstractController
if (array_key_exists('primaryColor', $data)) {
$hex = $data['primaryColor'] === '' ? null : trim($data['primaryColor']);
if ($hex !== null && !$this->brandColorService->isValid($hex)) {
- return $this->json(['error' => 'Ungültiger Hex-Farbwert.'], 422);
+ return $this->json(['error' => $this->translator->trans('app.error.invalid_hex')], 422);
}
$account->setPrimaryColor($hex);
}
@@ -110,29 +112,28 @@ class AccountController extends AbstractController
$accountUser = $this->accountUserRepo->findOneBy(['account' => $account, 'user' => $currentUser]);
if (!$accountUser?->isAdmin()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
- // Nur der aktuelle Superadmin darf den Besitzer übertragen
if ($account->getSuperAdminUser()?->getId() !== $currentUser->getId()) {
- return $this->json(['error' => 'Nur der aktuelle Kontoinhaber kann diese Funktion nutzen.'], 403);
+ return $this->json(['error' => $this->translator->trans('app.account.superadmin_only')], 403);
}
$data = json_decode($request->getContent(), true) ?? [];
$userId = (int) ($data['userId'] ?? 0);
if ($userId === $currentUser->getId()) {
- return $this->json(['error' => 'Du bist bereits Kontoinhaber.'], 400);
+ return $this->json(['error' => $this->translator->trans('app.account.already_owner')], 400);
}
$newOwner = $this->userRepo->find($userId);
if ($newOwner === null) {
- return $this->json(['error' => 'Benutzer nicht gefunden.'], 404);
+ return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404);
}
$newAccountUser = $this->accountUserRepo->findOneBy(['account' => $account, 'user' => $newOwner]);
if ($newAccountUser === null || !$newAccountUser->isAdmin() || $newAccountUser->isArchived()) {
- return $this->json(['error' => 'Der Benutzer muss aktiver Administrator sein.'], 400);
+ return $this->json(['error' => $this->translator->trans('app.account.new_owner_must_be_admin')], 400);
}
$account->setSuperAdminUser($newOwner);
@@ -160,7 +161,7 @@ class AccountController extends AbstractController
if ($newEmail !== $user->getEmail()) {
$existing = $this->userRepo->findOneBy(['email' => $newEmail]);
if ($existing !== null && $existing->getId() !== $user->getId()) {
- return $this->json(['error' => 'Diese E-Mail-Adresse wird bereits verwendet.'], 409);
+ return $this->json(['error' => $this->translator->trans('app.error.email_taken')], 409);
}
$user->setEmail($newEmail);
}
@@ -172,13 +173,13 @@ class AccountController extends AbstractController
if (!empty($data['newPassword'])) {
if (empty($data['currentPassword'])) {
- return $this->json(['error' => 'Aktuelles Passwort ist erforderlich.'], 400);
+ return $this->json(['error' => $this->translator->trans('app.validation.password_current_required')], 400);
}
if (!$this->passwordHasher->isPasswordValid($user, $data['currentPassword'])) {
- return $this->json(['error' => 'Das aktuelle Passwort ist falsch.'], 400);
+ return $this->json(['error' => $this->translator->trans('app.validation.password_current_wrong')], 400);
}
if (strlen($data['newPassword']) < 8) {
- return $this->json(['error' => 'Das neue Passwort muss mindestens 8 Zeichen haben.'], 400);
+ return $this->json(['error' => $this->translator->trans('app.validation.password_new_min_length')], 400);
}
$user->setPassword($this->passwordHasher->hashPassword($user, $data['newPassword']));
}
diff --git a/httpdocs/src/Controller/ClientController.php b/httpdocs/src/Controller/ClientController.php
index 71bce9e..60cb78d 100644
--- a/httpdocs/src/Controller/ClientController.php
+++ b/httpdocs/src/Controller/ClientController.php
@@ -12,14 +12,16 @@ use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Contracts\Translation\TranslatorInterface;
class ClientController extends AbstractController
{
public function __construct(
- private EntityManagerInterface $em,
- private ClientRepository $clientRepo,
- private TimeEntryRepository $timeEntryRepo,
- private readonly AccountRoleHelper $roleHelper,
+ private readonly EntityManagerInterface $em,
+ private readonly ClientRepository $clientRepo,
+ private readonly TimeEntryRepository $timeEntryRepo,
+ private readonly AccountRoleHelper $roleHelper,
+ private readonly TranslatorInterface $translator,
) {}
#[Route('/clients', name: 'client_index')]
@@ -37,12 +39,12 @@ class ClientController extends AbstractController
public function create(Request $request): JsonResponse
{
if ($this->roleHelper->isTracker()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$data = json_decode($request->getContent(), true);
if (empty($data['name'])) {
- return $this->json(['error' => 'Name ist erforderlich'], 400);
+ return $this->json(['error' => $this->translator->trans('app.error.name_required')], 400);
}
$client = new Client();
@@ -60,15 +62,15 @@ class ClientController extends AbstractController
public function update(int $id, Request $request): JsonResponse
{
if ($this->roleHelper->isTracker()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$client = $this->clientRepo->find($id);
- if (!$client) return $this->json(['error' => 'Nicht gefunden'], 404);
+ if (!$client) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404);
$data = json_decode($request->getContent(), true);
if (empty($data['name'])) {
- return $this->json(['error' => 'Name ist erforderlich'], 400);
+ return $this->json(['error' => $this->translator->trans('app.error.name_required')], 400);
}
$client->setName(trim($data['name']));
@@ -84,10 +86,10 @@ class ClientController extends AbstractController
public function delete(int $id): JsonResponse
{
if ($this->roleHelper->isTracker()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$client = $this->clientRepo->find($id);
- if (!$client) return $this->json(['error' => 'Nicht gefunden'], 404);
+ if (!$client) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404);
if ($this->timeEntryRepo->countByClient($client) > 0) {
return $this->json(['error' => 'has_dependencies', 'canArchive' => true], 409);
@@ -103,10 +105,10 @@ class ClientController extends AbstractController
public function archive(int $id): JsonResponse
{
if ($this->roleHelper->isTracker()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$client = $this->clientRepo->find($id);
- if (!$client) return $this->json(['error' => 'Nicht gefunden'], 404);
+ if (!$client) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404);
$client->setArchivedAt(new \DateTimeImmutable());
$this->em->flush();
@@ -118,10 +120,10 @@ class ClientController extends AbstractController
public function unarchive(int $id): JsonResponse
{
if ($this->roleHelper->isTracker()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$client = $this->clientRepo->find($id);
- if (!$client) return $this->json(['error' => 'Nicht gefunden'], 404);
+ if (!$client) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404);
$client->setArchivedAt(null);
$this->em->flush();
diff --git a/httpdocs/src/Controller/InviteController.php b/httpdocs/src/Controller/InviteController.php
index f794528..6924aac 100644
--- a/httpdocs/src/Controller/InviteController.php
+++ b/httpdocs/src/Controller/InviteController.php
@@ -26,41 +26,46 @@ class InviteController extends AbstractController
private readonly Security $security,
) {}
+ private function renderInviteError(string $errorKey): Response
+ {
+ return $this->render('invite/error.html.twig', ['error' => $errorKey]);
+ }
+
#[Route('/invite/{token}', name: 'app_invite')]
public function setPassword(string $token, Request $request): Response
{
$invite = $this->inviteTokenRepo->findOneBy(['token' => $token]);
if ($invite === null) {
- return $this->render('invite/error.html.twig', [
- 'error' => 'Dieser Einladungslink ist ungültig.',
- ]);
+ return $this->renderInviteError('link_invalid');
}
if ($invite->isExpired()) {
- return $this->render('invite/error.html.twig', [
- 'error' => 'Dieser Einladungslink ist abgelaufen (gültig 7 Tage).',
- ]);
+ return $this->renderInviteError('link_expired');
}
- // Account-Kontext prüfen (Sicherheit: Link muss auf richtigem Subdomain geöffnet werden)
$account = $this->tenantContext->getAccount();
if ($account === null || $account->getId() !== $invite->getAccount()?->getId()) {
- return $this->render('invite/error.html.twig', [
- 'error' => 'Dieser Einladungslink gehört zu einem anderen Account.',
- ]);
+ return $this->renderInviteError('link_wrong_account');
}
$error = null;
if ($request->isMethod('POST')) {
+ if (!$this->isCsrfTokenValid('invite_password', $request->request->get('_csrf_token'))) {
+ return $this->render('invite/set_password.html.twig', [
+ 'invite' => $invite,
+ 'error' => 'csrf',
+ ]);
+ }
+
$password = $request->request->get('password', '');
$passwordRepeat = $request->request->get('passwordRepeat', '');
if (strlen($password) < 8) {
- $error = 'Das Passwort muss mindestens 8 Zeichen haben.';
+ $error = 'too_short';
} elseif ($password !== $passwordRepeat) {
- $error = 'Die Passwörter stimmen nicht überein.';
+ $error = 'mismatch';
} else {
// User anlegen (oder existierenden finden, falls E-Mail schon vorhanden)
$user = $this->userRepo->findOneBy(['email' => $invite->getEmail()]);
diff --git a/httpdocs/src/Controller/PasswordResetController.php b/httpdocs/src/Controller/PasswordResetController.php
index a997ea8..75b96d2 100644
--- a/httpdocs/src/Controller/PasswordResetController.php
+++ b/httpdocs/src/Controller/PasswordResetController.php
@@ -18,6 +18,7 @@ use Symfony\Component\Mime\Address;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
class PasswordResetController extends AbstractController
{
@@ -30,6 +31,7 @@ class PasswordResetController extends AbstractController
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly Security $security,
private readonly UrlGeneratorInterface $urlGenerator,
+ private readonly TranslatorInterface $translator,
) {}
#[Route('/forgot-password', name: 'app_forgot_password', methods: ['GET', 'POST'])]
@@ -45,6 +47,15 @@ class PasswordResetController extends AbstractController
$error = null;
if ($request->isMethod('POST')) {
+ if (!$this->isCsrfTokenValid('forgot_password', $request->request->get('_csrf_token'))) {
+ $error = 'invalid_csrf';
+ return $this->render('security/forgot_password.html.twig', [
+ 'accountName' => $account->getName(),
+ 'sent' => false,
+ 'error' => $error,
+ ]);
+ }
+
$email = trim($request->request->get('email', ''));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
@@ -115,6 +126,16 @@ class PasswordResetController extends AbstractController
$error = null;
if ($request->isMethod('POST')) {
+ if (!$this->isCsrfTokenValid('reset_password', $request->request->get('_csrf_token'))) {
+ $error = 'invalid_csrf';
+ return $this->render('security/reset_password.html.twig', [
+ 'accountName' => $resetToken->getAccount()->getName(),
+ 'invalid' => false,
+ 'expired' => false,
+ 'error' => $error,
+ ]);
+ }
+
$password = $request->request->get('password', '');
$passwordRepeat = $request->request->get('passwordRepeat', '');
@@ -163,7 +184,7 @@ class PasswordResetController extends AbstractController
$email = (new TemplatedEmail())
->to(new Address($user->getEmail(), $user->getFullName()))
- ->subject('Passwort zurücksetzen – ' . $token->getAccount()->getName())
+ ->subject($this->translator->trans('app.email.password_reset.subject', ['%account%' => $token->getAccount()->getName()]))
->htmlTemplate('email/password_reset.html.twig')
->context([
'token' => $token,
diff --git a/httpdocs/src/Controller/ProjectController.php b/httpdocs/src/Controller/ProjectController.php
index f17bb35..52aa10c 100644
--- a/httpdocs/src/Controller/ProjectController.php
+++ b/httpdocs/src/Controller/ProjectController.php
@@ -13,15 +13,17 @@ use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Contracts\Translation\TranslatorInterface;
class ProjectController extends AbstractController
{
public function __construct(
- private EntityManagerInterface $em,
- private ProjectRepository $projectRepo,
- private ClientRepository $clientRepo,
- private TimeEntryRepository $timeEntryRepo,
- private readonly AccountRoleHelper $roleHelper,
+ private readonly EntityManagerInterface $em,
+ private readonly ProjectRepository $projectRepo,
+ private readonly ClientRepository $clientRepo,
+ private readonly TimeEntryRepository $timeEntryRepo,
+ private readonly AccountRoleHelper $roleHelper,
+ private readonly TranslatorInterface $translator,
) {}
#[Route('/projects', name: 'project_index')]
@@ -40,13 +42,13 @@ class ProjectController extends AbstractController
public function create(Request $request): JsonResponse
{
if ($this->roleHelper->isTracker()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$data = json_decode($request->getContent(), true);
$client = $this->clientRepo->find($data['clientId'] ?? 0);
- if (empty($data['name'])) return $this->json(['error' => 'Name ist erforderlich'], 400);
- if (!$client) return $this->json(['error' => 'Kunde nicht gefunden'], 400);
+ if (empty($data['name'])) return $this->json(['error' => $this->translator->trans('app.error.name_required')], 400);
+ if (!$client) return $this->json(['error' => $this->translator->trans('app.error.client_not_found')], 400);
$project = new Project();
$project->setName(trim($data['name']));
@@ -63,16 +65,16 @@ class ProjectController extends AbstractController
public function update(int $id, Request $request): JsonResponse
{
if ($this->roleHelper->isTracker()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$project = $this->projectRepo->find($id);
- if (!$project) return $this->json(['error' => 'Nicht gefunden'], 404);
+ if (!$project) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404);
$data = json_decode($request->getContent(), true);
$client = $this->clientRepo->find($data['clientId'] ?? 0);
- if (empty($data['name'])) return $this->json(['error' => 'Name ist erforderlich'], 400);
- if (!$client) return $this->json(['error' => 'Kunde nicht gefunden'], 400);
+ if (empty($data['name'])) return $this->json(['error' => $this->translator->trans('app.error.name_required')], 400);
+ if (!$client) return $this->json(['error' => $this->translator->trans('app.error.client_not_found')], 400);
$project->setName(trim($data['name']));
$project->setClient($client);
@@ -87,10 +89,10 @@ class ProjectController extends AbstractController
public function delete(int $id): JsonResponse
{
if ($this->roleHelper->isTracker()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$project = $this->projectRepo->find($id);
- if (!$project) return $this->json(['error' => 'Nicht gefunden'], 404);
+ if (!$project) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404);
if ($this->timeEntryRepo->countByProject($project) > 0) {
return $this->json(['error' => 'has_dependencies', 'canArchive' => true], 409);
@@ -106,10 +108,10 @@ class ProjectController extends AbstractController
public function archive(int $id): JsonResponse
{
if ($this->roleHelper->isTracker()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$project = $this->projectRepo->find($id);
- if (!$project) return $this->json(['error' => 'Nicht gefunden'], 404);
+ if (!$project) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404);
$project->setArchivedAt(new \DateTimeImmutable());
$this->em->flush();
@@ -121,10 +123,10 @@ class ProjectController extends AbstractController
public function unarchive(int $id): JsonResponse
{
if ($this->roleHelper->isTracker()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$project = $this->projectRepo->find($id);
- if (!$project) return $this->json(['error' => 'Nicht gefunden'], 404);
+ if (!$project) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404);
$project->setArchivedAt(null);
$this->em->flush();
diff --git a/httpdocs/src/Controller/RegistrationController.php b/httpdocs/src/Controller/RegistrationController.php
index 569374a..15052e8 100644
--- a/httpdocs/src/Controller/RegistrationController.php
+++ b/httpdocs/src/Controller/RegistrationController.php
@@ -10,12 +10,14 @@ use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Contracts\Translation\TranslatorInterface;
class RegistrationController extends AbstractController
{
public function __construct(
private readonly RegistrationService $registrationService,
private readonly SlugGenerator $slugGenerator,
+ private readonly TranslatorInterface $translator,
private readonly string $appDomain,
private readonly LoggerInterface $logger,
) {}
@@ -62,12 +64,12 @@ class RegistrationController extends AbstractController
$passwordRepeat = $data['passwordRepeat'] ?? '';
$errors = [];
- if ($companyName === '') { $errors[] = 'Firmenname ist erforderlich.'; }
- if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = 'Keine gültige E-Mail-Adresse.'; }
- if ($firstName === '') { $errors[] = 'Vorname ist erforderlich.'; }
- if ($lastName === '') { $errors[] = 'Nachname ist erforderlich.'; }
- if (strlen($password) < 8) { $errors[] = 'Passwort muss mindestens 8 Zeichen lang sein.'; }
- if ($password !== $passwordRepeat) { $errors[] = 'Passwörter stimmen nicht überein.'; }
+ if ($companyName === '') { $errors[] = $this->translator->trans('app.validation.company_name_required'); }
+ if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = $this->translator->trans('app.validation.email_invalid'); }
+ if ($firstName === '') { $errors[] = $this->translator->trans('app.validation.first_name_required'); }
+ if ($lastName === '') { $errors[] = $this->translator->trans('app.validation.last_name_required'); }
+ if (strlen($password) < 8) { $errors[] = $this->translator->trans('app.validation.password_min_length'); }
+ if ($password !== $passwordRepeat) { $errors[] = $this->translator->trans('app.validation.password_mismatch'); }
if (!empty($errors)) {
return $this->json(['errors' => $errors], Response::HTTP_UNPROCESSABLE_ENTITY);
@@ -82,11 +84,8 @@ class RegistrationController extends AbstractController
return $this->json(['errors' => [$e->getMessage()]], Response::HTTP_UNPROCESSABLE_ENTITY);
} catch (\Throwable $e) {
$this->logger->error('Registration failed: ' . $e->getMessage(), ['exception' => $e]);
- return $this->json(['errors' => ['Ein Fehler ist aufgetreten. Bitte versuche es erneut.']], Response::HTTP_INTERNAL_SERVER_ERROR);
+ return $this->json(['errors' => [$this->translator->trans('app.error.generic')]], Response::HTTP_INTERNAL_SERVER_ERROR);
}
-// } catch (\Throwable $e) {
-// return $this->json(['errors' => ['Ein Fehler ist aufgetreten. Bitte versuche es erneut.']], Response::HTTP_INTERNAL_SERVER_ERROR);
-// }
}
#[Route('/verify/{token}', name: 'app_verify')]
diff --git a/httpdocs/src/Controller/ReportController.php b/httpdocs/src/Controller/ReportController.php
index 9035973..3011937 100644
--- a/httpdocs/src/Controller/ReportController.php
+++ b/httpdocs/src/Controller/ReportController.php
@@ -7,16 +7,20 @@ use App\Repository\Tenant\ClientRepository;
use App\Repository\Tenant\TimeEntryRepository;
use App\Repository\Tenant\ProjectRepository;
use App\Repository\Tenant\ServiceRepository;
+use App\Service\ReportExportService;
use App\Service\TenantContext;
use App\Entity\Central\User;
use App\Service\AccountRoleHelper;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Contracts\Translation\TranslatorInterface;
class ReportController extends AbstractController
{
@@ -32,6 +36,8 @@ class ReportController extends AbstractController
private readonly ProjectRepository $projectRepo,
private readonly ServiceRepository $serviceRepo,
private readonly ClientRepository $clientRepo,
+ private readonly ReportExportService $exportService,
+ private readonly TranslatorInterface $translator,
) {}
#[Route('/reports/times', name: 'report_times')]
@@ -48,15 +54,11 @@ class ReportController extends AbstractController
$isAdmin = $this->roleHelper->isAdmin();
$isTracker = $this->roleHelper->isTracker();
- // User-Map: userId → vollständiger Name
- $account = $this->tenantContext->getAccount();
- $accountUsers = $this->accountUserRepo->findBy(['account' => $account]);
- $userMap = [];
- foreach ($accountUsers as $au) {
- $userMap[$au->getUser()->getId()] = $au->getUser()->getFullName();
- }
+ $userMap = $this->buildUserMap();
// User-Liste für Filter-Dropdown (für Twig/JS)
+ $account = $this->tenantContext->getAccount();
+ $accountUsers = $this->accountUserRepo->findBy(['account' => $account]);
$userList = [];
foreach ($accountUsers as $au) {
$userList[] = [
@@ -66,14 +68,8 @@ class ReportController extends AbstractController
];
}
- // Filter aus GET-Parametern lesen
$filterRaw = $request->query->all('filter');
- $filters = $this->parseFilters($filterRaw);
-
- // Tracker: immer auf eigenen User beschränken
- if ($isTracker) {
- $filters['userIds'] = [$currentUserId];
- }
+ $filters = $this->resolveFilters($request);
// Ob der Benutzer explizit Filter gesetzt hat (für "Alle anzeigen")
$filterActive = !empty($request->query->all('filter'));
@@ -126,6 +122,94 @@ class ReportController extends AbstractController
]);
}
+ // ── Excel-Export ─────────────────────────────────────────────────────────
+
+ #[Route('/reports/export/excel', name: 'report_export_excel')]
+ public function exportExcel(Request $request): BinaryFileResponse
+ {
+ $filters = $this->resolveFilters($request);
+ $entries = $this->timeEntryRepo->findAllFiltered($filters);
+
+ $accountName = $this->tenantContext->getAccount()?->getName() ?? '';
+ $userMap = $this->buildUserMap();
+
+ $tmpFile = $this->exportService->generateExcel($entries, $userMap, $accountName);
+ $filename = 'Zeitreport_' . $accountName . '_' . date('Y-m-d') . '.xlsx';
+
+ $response = new BinaryFileResponse($tmpFile);
+ $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
+ $response->deleteFileAfterSend(true);
+
+ return $response;
+ }
+
+ #[Route('/reports/export/csv', name: 'report_export_csv')]
+ public function exportCsv(Request $request): BinaryFileResponse
+ {
+ $filters = $this->resolveFilters($request);
+ $entries = $this->timeEntryRepo->findAllFiltered($filters);
+
+ $accountName = $this->tenantContext->getAccount()?->getName() ?? '';
+ $userMap = $this->buildUserMap();
+
+ $tmpFile = $this->exportService->generateCsv($entries, $userMap);
+ $filename = 'Zeitreport_' . $accountName . '_' . date('Y-m-d') . '.csv';
+
+ $response = new BinaryFileResponse($tmpFile);
+ $response->headers->set('Content-Type', 'text/csv; charset=UTF-8');
+ $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
+ $response->deleteFileAfterSend(true);
+
+ return $response;
+ }
+
+ #[Route('/reports/export/pdf', name: 'report_export_pdf')]
+ public function exportPdf(Request $request): BinaryFileResponse
+ {
+ $filters = $this->resolveFilters($request);
+ $entries = $this->timeEntryRepo->findAllFiltered($filters);
+
+ $accountName = $this->tenantContext->getAccount()?->getName() ?? '';
+ $userMap = $this->buildUserMap();
+
+ $tmpFile = $this->exportService->generatePdf($entries, $userMap, $accountName);
+ $filename = 'Zeitreport_' . $accountName . '_' . date('Y-m-d') . '.pdf';
+
+ $response = new BinaryFileResponse($tmpFile);
+ $response->headers->set('Content-Type', 'application/pdf');
+ $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
+ $response->deleteFileAfterSend(true);
+
+ return $response;
+ }
+
+ // ── Shared Helpers ───────────────────────────────────────────────────────
+
+ private function resolveFilters(Request $request): array
+ {
+ $filterRaw = $request->query->all('filter');
+ $filters = $this->parseFilters($filterRaw);
+
+ if ($this->roleHelper->isTracker()) {
+ /** @var User $currentUser */
+ $currentUser = $this->security->getUser();
+ $filters['userIds'] = [$currentUser->getId()];
+ }
+
+ return $filters;
+ }
+
+ private function buildUserMap(): array
+ {
+ $account = $this->tenantContext->getAccount();
+ $accountUsers = $this->accountUserRepo->findBy(['account' => $account]);
+ $userMap = [];
+ foreach ($accountUsers as $au) {
+ $userMap[$au->getUser()->getId()] = $au->getUser()->getFullName();
+ }
+ return $userMap;
+ }
+
// ── Filter-Parsing ────────────────────────────────────────────────────────
private function parseFilters(array $f): array
@@ -250,13 +334,13 @@ class ReportController extends AbstractController
{
$entry = $this->timeEntryRepo->find($id);
if (!$entry) {
- return $this->json(['error' => 'Nicht gefunden'], 404);
+ return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404);
}
/** @var User $currentUser */
$currentUser = $this->security->getUser();
if (!$this->roleHelper->isAdmin() && $entry->getUserId() !== $currentUser->getId()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$entry->setInvoiced(!$entry->isInvoiced());
diff --git a/httpdocs/src/Controller/ServiceController.php b/httpdocs/src/Controller/ServiceController.php
index 71916e5..db88b04 100644
--- a/httpdocs/src/Controller/ServiceController.php
+++ b/httpdocs/src/Controller/ServiceController.php
@@ -12,14 +12,16 @@ use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Contracts\Translation\TranslatorInterface;
class ServiceController extends AbstractController
{
public function __construct(
- private EntityManagerInterface $em,
- private ServiceRepository $serviceRepo,
- private TimeEntryRepository $timeEntryRepo,
- private readonly AccountRoleHelper $roleHelper,
+ private readonly EntityManagerInterface $em,
+ private readonly ServiceRepository $serviceRepo,
+ private readonly TimeEntryRepository $timeEntryRepo,
+ private readonly AccountRoleHelper $roleHelper,
+ private readonly TranslatorInterface $translator,
) {}
#[Route('/services', name: 'service_index')]
@@ -37,11 +39,11 @@ class ServiceController extends AbstractController
public function create(Request $request): JsonResponse
{
if ($this->roleHelper->isTracker()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$data = json_decode($request->getContent(), true);
- if (empty($data['name'])) return $this->json(['error' => 'Name ist erforderlich'], 400);
+ if (empty($data['name'])) return $this->json(['error' => $this->translator->trans('app.error.name_required')], 400);
$service = new Service();
$service->setName(trim($data['name']));
@@ -58,14 +60,14 @@ class ServiceController extends AbstractController
public function update(int $id, Request $request): JsonResponse
{
if ($this->roleHelper->isTracker()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$service = $this->serviceRepo->find($id);
- if (!$service) return $this->json(['error' => 'Nicht gefunden'], 404);
+ if (!$service) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404);
$data = json_decode($request->getContent(), true);
- if (empty($data['name'])) return $this->json(['error' => 'Name ist erforderlich'], 400);
+ if (empty($data['name'])) return $this->json(['error' => $this->translator->trans('app.error.name_required')], 400);
$service->setName(trim($data['name']));
$service->setBillable((bool) ($data['billable'] ?? true));
@@ -80,10 +82,10 @@ class ServiceController extends AbstractController
public function delete(int $id): JsonResponse
{
if ($this->roleHelper->isTracker()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$service = $this->serviceRepo->find($id);
- if (!$service) return $this->json(['error' => 'Nicht gefunden'], 404);
+ if (!$service) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404);
if ($this->timeEntryRepo->countByService($service) > 0) {
return $this->json(['error' => 'has_dependencies', 'canArchive' => true], 409);
@@ -99,10 +101,10 @@ class ServiceController extends AbstractController
public function archive(int $id): JsonResponse
{
if ($this->roleHelper->isTracker()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$service = $this->serviceRepo->find($id);
- if (!$service) return $this->json(['error' => 'Nicht gefunden'], 404);
+ if (!$service) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404);
$service->setArchivedAt(new \DateTimeImmutable());
$this->em->flush();
@@ -114,10 +116,10 @@ class ServiceController extends AbstractController
public function unarchive(int $id): JsonResponse
{
if ($this->roleHelper->isTracker()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$service = $this->serviceRepo->find($id);
- if (!$service) return $this->json(['error' => 'Nicht gefunden'], 404);
+ if (!$service) return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404);
$service->setArchivedAt(null);
$this->em->flush();
diff --git a/httpdocs/src/Controller/TeamController.php b/httpdocs/src/Controller/TeamController.php
index 2d961a0..4cc4f1d 100644
--- a/httpdocs/src/Controller/TeamController.php
+++ b/httpdocs/src/Controller/TeamController.php
@@ -21,6 +21,7 @@ use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
class TeamController extends AbstractController
{
@@ -34,6 +35,7 @@ class TeamController extends AbstractController
private readonly AccountRoleHelper $roleHelper,
private readonly MailerInterface $mailer,
private readonly UrlGeneratorInterface $urlGenerator,
+ private readonly TranslatorInterface $translator,
private readonly string $appDomain,
) {}
@@ -63,7 +65,7 @@ class TeamController extends AbstractController
public function invite(Request $request): JsonResponse
{
if (!$this->roleHelper->isAdmin()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$data = json_decode($request->getContent(), true) ?? [];
@@ -73,12 +75,12 @@ class TeamController extends AbstractController
$role = $data['role'] ?? AccountUser::ROLE_MEMBER;
$errors = [];
- if ($email === '') { $errors[] = 'E-Mail ist erforderlich.'; }
- if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = 'Keine gültige E-Mail-Adresse.'; }
- if ($firstName === '') { $errors[] = 'Vorname ist erforderlich.'; }
- if ($lastName === '') { $errors[] = 'Nachname ist erforderlich.'; }
+ if ($email === '') { $errors[] = $this->translator->trans('app.validation.email_required'); }
+ if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = $this->translator->trans('app.validation.email_invalid'); }
+ if ($firstName === '') { $errors[] = $this->translator->trans('app.validation.first_name_required'); }
+ if ($lastName === '') { $errors[] = $this->translator->trans('app.validation.last_name_required'); }
if (!in_array($role, [AccountUser::ROLE_ADMIN, AccountUser::ROLE_MEMBER, AccountUser::ROLE_TRACKER], true)) {
- $errors[] = 'Ungültige Rolle.';
+ $errors[] = $this->translator->trans('app.error.invalid_role');
}
if (!empty($errors)) {
@@ -94,7 +96,7 @@ class TeamController extends AbstractController
'user' => $existingUser,
]);
if ($alreadyMember !== null) {
- return $this->json(['errors' => ['Diese Person ist bereits Mitglied dieses Accounts.']], 409);
+ return $this->json(['errors' => [$this->translator->trans('app.team.already_member')]], 409);
}
}
@@ -124,22 +126,22 @@ class TeamController extends AbstractController
public function archive(int $id): JsonResponse
{
if (!$this->roleHelper->isAdmin()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$account = $this->tenantContext->getAccount();
$accountUser = $this->accountUserRepo->find($id);
if ($accountUser === null || $accountUser->getAccount()?->getId() !== $account?->getId()) {
- return $this->json(['error' => 'Nicht gefunden'], 404);
+ return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404);
}
if ($accountUser->getUser() === $this->getUser()) {
- return $this->json(['error' => 'Du kannst dich nicht selbst archivieren.'], 400);
+ return $this->json(['error' => $this->translator->trans('app.team.cannot_archive_self')], 400);
}
if ($account->getSuperAdminUser()?->getId() === $accountUser->getUser()->getId()) {
- return $this->json(['error' => 'Der Kontoinhaber kann nicht archiviert werden.'], 403);
+ return $this->json(['error' => $this->translator->trans('app.team.cannot_archive_owner')], 403);
}
$accountUser->setArchivedAt(new \DateTimeImmutable());
@@ -152,14 +154,14 @@ class TeamController extends AbstractController
public function unarchive(int $id): JsonResponse
{
if (!$this->roleHelper->isAdmin()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$account = $this->tenantContext->getAccount();
$accountUser = $this->accountUserRepo->find($id);
if ($accountUser === null || $accountUser->getAccount()?->getId() !== $account?->getId()) {
- return $this->json(['error' => 'Nicht gefunden'], 404);
+ return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404);
}
$accountUser->setArchivedAt(null);
@@ -172,30 +174,30 @@ class TeamController extends AbstractController
public function edit(int $id, Request $request): JsonResponse
{
if (!$this->roleHelper->isAdmin()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$account = $this->tenantContext->getAccount();
$accountUser = $this->accountUserRepo->find($id);
if ($accountUser === null || $accountUser->getAccount()?->getId() !== $account?->getId()) {
- return $this->json(['error' => 'Nicht gefunden'], 404);
+ return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404);
}
$data = json_decode($request->getContent(), true) ?? [];
$firstName = trim($data['firstName'] ?? '');
$lastName = trim($data['lastName'] ?? '');
$email = trim($data['email'] ?? '');
- $note = $data['note'] !== '' ? ($data['note'] ?? null) : null;
+ $note = !empty($data['note']) ? $data['note'] : null;
$role = $data['role'] ?? null;
$errors = [];
- if ($firstName === '') { $errors[] = 'Vorname ist erforderlich.'; }
- if ($lastName === '') { $errors[] = 'Nachname ist erforderlich.'; }
- if ($email === '') { $errors[] = 'E-Mail ist erforderlich.'; }
- if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = 'Keine gültige E-Mail-Adresse.'; }
+ if ($firstName === '') { $errors[] = $this->translator->trans('app.validation.first_name_required'); }
+ if ($lastName === '') { $errors[] = $this->translator->trans('app.validation.last_name_required'); }
+ if ($email === '') { $errors[] = $this->translator->trans('app.validation.email_required'); }
+ if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = $this->translator->trans('app.validation.email_invalid'); }
if ($role !== null && !in_array($role, [AccountUser::ROLE_ADMIN, AccountUser::ROLE_MEMBER, AccountUser::ROLE_TRACKER], true)) {
- $errors[] = 'Ungültige Rolle.';
+ $errors[] = $this->translator->trans('app.error.invalid_role');
}
if (!empty($errors)) {
@@ -208,14 +210,13 @@ class TeamController extends AbstractController
if ($email !== $user->getEmail()) {
$existing = $this->userRepo->findOneBy(['email' => $email]);
if ($existing !== null) {
- return $this->json(['errors' => ['Diese E-Mail-Adresse wird bereits verwendet.']], 409);
+ return $this->json(['errors' => [$this->translator->trans('app.error.email_taken')]], 409);
}
}
- // Eigene Rolle: Admin darf sich nicht selbst degradieren
$isSelf = ($user === $this->getUser());
if ($isSelf && $accountUser->isAdmin() && $role !== null && $role !== AccountUser::ROLE_ADMIN) {
- return $this->json(['errors' => ['Du kannst deine eigene Administratoren-Rolle nicht ändern.']], 400);
+ return $this->json(['errors' => [$this->translator->trans('app.team.cannot_change_own_role')]], 400);
}
$user->setFirstName($firstName);
@@ -236,22 +237,22 @@ class TeamController extends AbstractController
public function delete(int $id): JsonResponse
{
if (!$this->roleHelper->isAdmin()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$account = $this->tenantContext->getAccount();
$accountUser = $this->accountUserRepo->find($id);
if ($accountUser === null || $accountUser->getAccount()?->getId() !== $account?->getId()) {
- return $this->json(['error' => 'Nicht gefunden'], 404);
+ return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404);
}
if ($accountUser->getUser() === $this->getUser()) {
- return $this->json(['error' => 'Du kannst dich nicht selbst entfernen.'], 400);
+ return $this->json(['error' => $this->translator->trans('app.team.cannot_remove_self')], 400);
}
if ($account->getSuperAdminUser()?->getId() === $accountUser->getUser()->getId()) {
- return $this->json(['error' => 'Der Kontoinhaber kann nicht entfernt werden.'], 403);
+ return $this->json(['error' => $this->translator->trans('app.team.cannot_remove_owner')], 403);
}
$userId = $accountUser->getUser()->getId();
@@ -269,14 +270,14 @@ class TeamController extends AbstractController
public function deleteInvite(int $id): JsonResponse
{
if (!$this->roleHelper->isAdmin()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$account = $this->tenantContext->getAccount();
$invite = $this->inviteTokenRepo->find($id);
if ($invite === null || $invite->getAccount()?->getId() !== $account?->getId()) {
- return $this->json(['error' => 'Nicht gefunden'], 404);
+ return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404);
}
$this->em->remove($invite);
@@ -295,7 +296,7 @@ class TeamController extends AbstractController
'email' => $au->getUser()->getEmail(),
'note' => $au->getUser()->getNote(),
'role' => $au->getRole(),
- 'roleLabel' => $au->getRoleLabel(),
+ 'roleLabel' => $this->translator->trans('app.role.' . $au->getRole()),
];
}
@@ -309,7 +310,7 @@ class TeamController extends AbstractController
$email = (new TemplatedEmail())
->to(new Address($invite->getEmail(), $invite->getFirstName() . ' ' . $invite->getLastName()))
- ->subject('Einladung zu ' . $invite->getAccount()->getName())
+ ->subject($this->translator->trans('app.email.invite.subject', ['%company%' => $invite->getAccount()->getName()]))
->htmlTemplate('email/team_invite.html.twig')
->context([
'invite' => $invite,
diff --git a/httpdocs/src/Controller/TimeTrackingController.php b/httpdocs/src/Controller/TimeTrackingController.php
index 4acf15a..9ca9ee9 100644
--- a/httpdocs/src/Controller/TimeTrackingController.php
+++ b/httpdocs/src/Controller/TimeTrackingController.php
@@ -16,6 +16,7 @@ use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Contracts\Translation\TranslatorInterface;
class TimeTrackingController extends AbstractController
{
@@ -27,6 +28,7 @@ class TimeTrackingController extends AbstractController
private readonly TenantContext $tenantContext,
private readonly AccountRoleHelper $roleHelper,
private readonly Security $security,
+ private readonly TranslatorInterface $translator,
) {}
// ── Hauptseite ────────────────────────────────────────────────────────────
@@ -106,7 +108,7 @@ class TimeTrackingController extends AbstractController
$project = $this->projectRepo->find($data['projectId'] ?? 0);
if (!$project) {
- return $this->json(['error' => 'Projekt nicht gefunden'], 400);
+ return $this->json(['error' => $this->translator->trans('app.error.project_not_found')], 400);
}
$tz = new \DateTimeZone('Europe/Berlin');
@@ -120,7 +122,7 @@ class TimeTrackingController extends AbstractController
$newDuration = $this->parseDuration($data['duration'] ?? '0');
$currentTotal = $this->timeEntryRepo->sumDurationByDateAndUserId($date, $user->getId());
if ($currentTotal + $newDuration > 1440) {
- return $this->json(['error' => 'Du kannst nicht mehr als 24 Stunden pro Tag loggen.'], 422);
+ return $this->json(['error' => $this->translator->trans('app.error.daily_limit')], 422);
}
$entry = new TimeEntry();
@@ -149,20 +151,20 @@ class TimeTrackingController extends AbstractController
{
$entry = $this->timeEntryRepo->find($id);
if (!$entry) {
- return $this->json(['error' => 'Nicht gefunden'], 404);
+ return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404);
}
/** @var User $currentUser */
$currentUser = $this->security->getUser();
if (!$this->roleHelper->isAdmin() && $entry->getUserId() !== $currentUser->getId()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$data = json_decode($request->getContent(), true);
$project = $this->projectRepo->find($data['projectId'] ?? 0);
if (!$project) {
- return $this->json(['error' => 'Projekt nicht gefunden'], 400);
+ return $this->json(['error' => $this->translator->trans('app.error.project_not_found')], 400);
}
$service = null;
@@ -173,7 +175,7 @@ class TimeTrackingController extends AbstractController
$newDuration = $this->parseDuration($data['duration'] ?? '0');
$currentTotal = $this->timeEntryRepo->sumDurationByDateAndUserId($entry->getDate(), $entry->getUserId());
if ($currentTotal - $entry->getDuration() + $newDuration > 1440) {
- return $this->json(['error' => 'Du kannst nicht mehr als 24 Stunden pro Tag loggen.'], 422);
+ return $this->json(['error' => $this->translator->trans('app.error.daily_limit')], 422);
}
$entry->setProject($project);
@@ -201,13 +203,13 @@ class TimeTrackingController extends AbstractController
{
$entry = $this->timeEntryRepo->find($id);
if (!$entry) {
- return $this->json(['error' => 'Nicht gefunden'], 404);
+ return $this->json(['error' => $this->translator->trans('app.error.not_found')], 404);
}
/** @var User $currentUser */
$currentUser = $this->security->getUser();
if (!$this->roleHelper->isAdmin() && $entry->getUserId() !== $currentUser->getId()) {
- return $this->json(['error' => 'Zugriff verweigert'], 403);
+ return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}
$date = $entry->getDate();
@@ -261,13 +263,15 @@ class TimeTrackingController extends AbstractController
{
$hour = (int) (new \DateTimeImmutable('now', new \DateTimeZone('Europe/Berlin')))->format('H');
- return match(true) {
- $hour >= 5 && $hour < 11 => 'Guten Morgen',
- $hour >= 11 && $hour < 14 => 'Mahlzeit',
- $hour >= 14 && $hour < 18 => 'Guten Tag',
- $hour >= 18 && $hour < 22 => 'Guten Abend',
- default => 'Gute Nacht',
+ $key = match(true) {
+ $hour >= 5 && $hour < 11 => 'app.greeting.morning',
+ $hour >= 11 && $hour < 14 => 'app.greeting.noon',
+ $hour >= 14 && $hour < 18 => 'app.greeting.afternoon',
+ $hour >= 18 && $hour < 22 => 'app.greeting.evening',
+ default => 'app.greeting.night',
};
+
+ return $this->translator->trans($key);
}
private function parseDuration(string $input): int
diff --git a/httpdocs/src/Entity/Central/AccountUser.php b/httpdocs/src/Entity/Central/AccountUser.php
index 54eb647..89f7ae2 100644
--- a/httpdocs/src/Entity/Central/AccountUser.php
+++ b/httpdocs/src/Entity/Central/AccountUser.php
@@ -53,13 +53,8 @@ class AccountUser
public function isTracker(): bool { return $this->role === self::ROLE_TRACKER; }
public function isMemberOrAdmin(): bool { return $this->isAdmin() || $this->isMember(); }
- public function getRoleLabel(): string
+ public function getRoleLabelKey(): string
{
- return match ($this->role) {
- self::ROLE_ADMIN => 'Administrator',
- self::ROLE_MEMBER => 'Standard',
- self::ROLE_TRACKER => 'Zeiterfasser',
- default => $this->role,
- };
+ return 'app.role.' . $this->role;
}
}
\ No newline at end of file
diff --git a/httpdocs/src/EventSubscriber/ArchivedUserSubscriber.php b/httpdocs/src/EventSubscriber/ArchivedUserSubscriber.php
index cb6913b..79d71dd 100644
--- a/httpdocs/src/EventSubscriber/ArchivedUserSubscriber.php
+++ b/httpdocs/src/EventSubscriber/ArchivedUserSubscriber.php
@@ -11,6 +11,7 @@ use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
class ArchivedUserSubscriber implements EventSubscriberInterface
{
@@ -19,9 +20,8 @@ class ArchivedUserSubscriber implements EventSubscriberInterface
private readonly AccountUserRepository $accountUserRepo,
private readonly TenantContext $tenantContext,
private readonly RouterInterface $router,
- )
- {
- }
+ private readonly TranslatorInterface $translator,
+ ) {}
public function onKernelRequest(RequestEvent $event): void
{
@@ -59,7 +59,7 @@ class ArchivedUserSubscriber implements EventSubscriberInterface
// API: 401, sonst Redirect zu Login
if (str_starts_with($request->getPathInfo(), '/api/')) {
- $event->setResponse(new JsonResponse(['error' => 'Konto deaktiviert.'], 401));
+ $event->setResponse(new JsonResponse(['error' => $this->translator->trans('app.account.deactivated_api')], 401));
} else {
$event->setResponse(new RedirectResponse($this->router->generate('app_login')));
}
diff --git a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php
index 9ec0727..12564d6 100644
--- a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php
+++ b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php
@@ -46,104 +46,6 @@ class TimeEntryRepository extends ServiceEntityRepository
return (int) $result;
}
- // ── Report ────────────────────────────────────────────────────────────────
-
- public function findForReport(int $limit = 50): array
- {
- return $this->createQueryBuilder('t')
- ->join('t.project', 'p')
- ->join('p.client', 'c')
- ->leftJoin('t.service', 's')
- ->addSelect('p', 'c', 's')
- ->orderBy('t.date', 'DESC')
- ->addOrderBy('t.createdAt', 'DESC')
- ->setMaxResults($limit)
- ->getQuery()
- ->getResult();
- }
-
- public function countAll(): int
- {
- return (int) $this->createQueryBuilder('t')
- ->select('COUNT(t.id)')
- ->getQuery()
- ->getSingleScalarResult();
- }
-
- public function sumDurationAll(): int
- {
- $result = $this->createQueryBuilder('t')
- ->select('SUM(t.duration)')
- ->getQuery()
- ->getSingleScalarResult();
-
- return (int) $result;
- }
-
- public function sumRevenueAll(): float
- {
- $result = $this->createQueryBuilder('t')
- ->select('SUM(c.hourlyRate * t.duration / 60)')
- ->join('t.project', 'p')
- ->join('p.client', 'c')
- ->leftJoin('t.service', 's')
- ->where('c.hourlyRate IS NOT NULL')
- ->andWhere('(s IS NULL OR s.billable = :billable)')
- ->setParameter('billable', true)
- ->getQuery()
- ->getSingleScalarResult();
-
- return (float) ($result ?? 0.0);
- }
-
- // ── Report: nach User gefiltert (für Tracker) ─────────────────────────────
-
- public function findForReportByUserId(int $userId, int $limit = 50): array
- {
- return $this->createQueryBuilder('t')
- ->join('t.project', 'p')
- ->join('p.client', 'c')
- ->leftJoin('t.service', 's')
- ->addSelect('p', 'c', 's')
- ->where('t.userId = :userId')
- ->setParameter('userId', $userId)
- ->orderBy('t.date', 'DESC')
- ->addOrderBy('t.createdAt', 'DESC')
- ->setMaxResults($limit)
- ->getQuery()
- ->getResult();
- }
-
- public function sumDurationByUserId(int $userId): int
- {
- $result = $this->createQueryBuilder('t')
- ->select('SUM(t.duration)')
- ->where('t.userId = :userId')
- ->setParameter('userId', $userId)
- ->getQuery()
- ->getSingleScalarResult();
-
- return (int) $result;
- }
-
- public function sumRevenueByUserId(int $userId): float
- {
- $result = $this->createQueryBuilder('t')
- ->select('SUM(c.hourlyRate * t.duration / 60)')
- ->join('t.project', 'p')
- ->join('p.client', 'c')
- ->leftJoin('t.service', 's')
- ->where('t.userId = :userId')
- ->andWhere('c.hourlyRate IS NOT NULL')
- ->andWhere('(s IS NULL OR s.billable = :billable)')
- ->setParameter('userId', $userId)
- ->setParameter('billable', true)
- ->getQuery()
- ->getSingleScalarResult();
-
- return (float) ($result ?? 0.0);
- }
-
// ── Zähler für abhängige Entitäten ────────────────────────────────────────
public function countByProject(Project $project): int
@@ -263,6 +165,16 @@ class TimeEntryRepository extends ServiceEntityRepository
->getResult();
}
+ public function findAllFiltered(array $filters): array
+ {
+ return $this->buildFilteredQuery($filters)
+ ->addSelect('p', 'c', 's')
+ ->orderBy('t.date', 'DESC')
+ ->addOrderBy('t.createdAt', 'DESC')
+ ->getQuery()
+ ->getResult();
+ }
+
public function countFiltered(array $filters): int
{
return (int) $this->buildFilteredQuery($filters)
diff --git a/httpdocs/src/Security/ArchivedUserChecker.php b/httpdocs/src/Security/ArchivedUserChecker.php
index 7e92399..80276b9 100644
--- a/httpdocs/src/Security/ArchivedUserChecker.php
+++ b/httpdocs/src/Security/ArchivedUserChecker.php
@@ -7,15 +7,15 @@ use App\Service\TenantContext;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
class ArchivedUserChecker implements UserCheckerInterface
{
public function __construct(
private readonly AccountUserRepository $accountUserRepo,
private readonly TenantContext $tenantContext,
- )
- {
- }
+ private readonly TranslatorInterface $translator,
+ ) {}
public function checkPreAuth(UserInterface $user): void
{
@@ -34,7 +34,7 @@ class ArchivedUserChecker implements UserCheckerInterface
]);
if ($accountUser !== null && $accountUser->isArchived()) {
- throw new CustomUserMessageAccountStatusException('Dein Konto wurde deaktiviert.');
+ throw new CustomUserMessageAccountStatusException($this->translator->trans('app.account.deactivated'));
}
}
}
\ No newline at end of file
diff --git a/httpdocs/src/Service/AccountRoleHelper.php b/httpdocs/src/Service/AccountRoleHelper.php
index ace2ed3..8f1cf53 100644
--- a/httpdocs/src/Service/AccountRoleHelper.php
+++ b/httpdocs/src/Service/AccountRoleHelper.php
@@ -12,6 +12,9 @@ use Symfony\Bundle\SecurityBundle\Security;
*/
class AccountRoleHelper
{
+ private ?AccountUser $cached = null;
+ private bool $resolved = false;
+
public function __construct(
private readonly Security $security,
private readonly TenantContext $tenantContext,
@@ -20,6 +23,10 @@ class AccountRoleHelper
public function getCurrentAccountUser(): ?AccountUser
{
+ if ($this->resolved) {
+ return $this->cached;
+ }
+
$user = $this->security->getUser();
$account = $this->tenantContext->getAccount();
@@ -27,10 +34,13 @@ class AccountRoleHelper
return null;
}
- return $this->accountUserRepo->findOneBy([
+ $this->cached = $this->accountUserRepo->findOneBy([
'account' => $account,
'user' => $user,
]);
+ $this->resolved = true;
+
+ return $this->cached;
}
public function isAdmin(): bool { return $this->getCurrentAccountUser()?->isAdmin() ?? false; }
diff --git a/httpdocs/src/Service/RegistrationService.php b/httpdocs/src/Service/RegistrationService.php
index fb70164..bf05ff4 100644
--- a/httpdocs/src/Service/RegistrationService.php
+++ b/httpdocs/src/Service/RegistrationService.php
@@ -13,6 +13,7 @@ use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
class RegistrationService
{
@@ -24,6 +25,7 @@ class RegistrationService
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly SlugGenerator $slugGenerator,
+ private readonly TranslatorInterface $translator,
private readonly string $appDomain,
private readonly string $notifyEmail,
) {}
@@ -41,7 +43,7 @@ class RegistrationService
// E-Mail bereits vergeben?
$existingUser = $this->centralEm->getRepository(User::class)->findOneBy(['email' => $email]);
if ($existingUser !== null) {
- throw new \DomainException('Diese E-Mail-Adresse wird bereits verwendet.');
+ throw new \DomainException($this->translator->trans('app.registration.email_taken'));
}
// Pending Token für dieselbe E-Mail? (doppeltes Absenden verhindern)
@@ -83,13 +85,13 @@ class RegistrationService
$token = $this->centralEm->getRepository(RegistrationToken::class)->findOneBy(['token' => $tokenString]);
if ($token === null) {
- throw new \InvalidArgumentException('Ungültiger Bestätigungslink.');
+ throw new \InvalidArgumentException($this->translator->trans('app.registration.confirm_invalid'));
}
if ($token->isExpired()) {
$this->centralEm->remove($token);
$this->centralEm->flush();
- throw new \InvalidArgumentException('Dieser Link ist abgelaufen (gültig 24 Stunden). Bitte registriere dich erneut.');
+ throw new \InvalidArgumentException($this->translator->trans('app.registration.confirm_expired'));
}
// Account anlegen
@@ -150,7 +152,7 @@ class RegistrationService
$email = (new TemplatedEmail())
->to(new Address($token->getEmail(), $token->getFirstName() . ' ' . $token->getLastName()))
- ->subject('Bitte bestätige deine Registrierung – spawntree Timetracker')
+ ->subject($this->translator->trans('app.email.confirm.subject'))
->htmlTemplate('email/registration_confirm.html.twig')
->context([
'token' => $token,
@@ -166,7 +168,7 @@ class RegistrationService
$email = (new TemplatedEmail())
->to(new Address($user->getEmail(), $user->getFullName()))
- ->subject('Willkommen beim spawntree Timetracker!')
+ ->subject($this->translator->trans('app.email.welcome.subject'))
->htmlTemplate('email/registration_welcome.html.twig')
->context([
'user' => $user,
@@ -181,7 +183,7 @@ class RegistrationService
{
$email = (new TemplatedEmail())
->to($this->notifyEmail)
- ->subject('[Timetracker] Neue Registrierung: ' . $account->getName())
+ ->subject($this->translator->trans('app.email.notify.subject', ['%name%' => $account->getName()]))
->htmlTemplate('email/registration_notify.html.twig')
->context([
'user' => $user,
diff --git a/httpdocs/src/Service/ReportExportService.php b/httpdocs/src/Service/ReportExportService.php
new file mode 100644
index 0000000..41613a5
--- /dev/null
+++ b/httpdocs/src/Service/ReportExportService.php
@@ -0,0 +1,395 @@
+translator->trans($id, $params);
+ }
+
+ private function headers(): array
+ {
+ return array_map(fn(string $key) => $this->t($key), self::HEADER_KEYS);
+ }
+
+ // ── Data Preparation ─────────────────────────────────────────────────────
+
+ /**
+ * @param TimeEntry[] $entries
+ * @param array
$userMap
+ * @return array{rows: list, totalHours: float, totalRevenue: float}
+ */
+ private function prepareData(array $entries, array $userMap): array
+ {
+ $rows = [];
+ $totalMinutes = 0;
+ $totalRevenue = 0.0;
+
+ foreach ($entries as $entry) {
+ $service = $entry->getService();
+ $client = $entry->getProject()?->getClient();
+ $billable = $service === null || $service->isBillable();
+ $rate = $client?->getHourlyRate();
+ $hours = $entry->getDuration() / 60;
+ $revenue = ($billable && $rate !== null) ? $rate * $hours : null;
+
+ $totalMinutes += $entry->getDuration();
+ if ($revenue !== null) {
+ $totalRevenue += $revenue;
+ }
+
+ $rows[] = [
+ 'date' => $entry->getDate(),
+ 'client' => $client?->getName() ?? '',
+ 'project' => $entry->getProject()?->getName() ?? '',
+ 'service' => $service?->getName() ?? '',
+ 'user' => $userMap[$entry->getUserId()] ?? $this->t('app.report.user_fallback', ['%id%' => $entry->getUserId()]),
+ 'note' => $entry->getNote() ?? '',
+ 'hours' => $hours,
+ 'revenue' => $revenue,
+ 'invoiced' => $entry->isInvoiced(),
+ ];
+ }
+
+ return [
+ 'rows' => $rows,
+ 'totalHours' => $totalMinutes / 60,
+ 'totalRevenue' => $totalRevenue,
+ ];
+ }
+
+ // ── Excel ────────────────────────────────────────────────────────────────
+
+ /**
+ * @param TimeEntry[] $entries
+ * @param array $userMap
+ */
+ public function generateExcel(array $entries, array $userMap, string $accountName): string
+ {
+ $data = $this->prepareData($entries, $userMap);
+
+ $spreadsheet = new Spreadsheet();
+ $spreadsheet->getProperties()
+ ->setTitle($this->t('app.report.export_title', ['%account%' => $accountName]))
+ ->setCreator($accountName);
+
+ $sheet = $spreadsheet->getActiveSheet();
+ $sheet->setTitle($this->t('app.report.export_col_hours'));
+
+ $this->excelWriteHeader($sheet);
+ $lastRow = $this->excelWriteData($sheet, $data['rows']);
+ $this->excelWriteSummary($sheet, $data, $lastRow + 1);
+ $this->excelApplyStyles($sheet, $lastRow);
+
+ $tmpFile = tempnam(sys_get_temp_dir(), 'report_') . '.xlsx';
+ (new Xlsx($spreadsheet))->save($tmpFile);
+ $spreadsheet->disconnectWorksheets();
+
+ return $tmpFile;
+ }
+
+ private function excelWriteHeader(Worksheet $sheet): void
+ {
+ $headers = $this->headers();
+ $cols = range('A', 'I');
+
+ foreach ($cols as $i => $col) {
+ $sheet->setCellValue($col . '1', $headers[$i]);
+ $sheet->getColumnDimension($col)->setWidth(self::EXCEL_WIDTHS[$i]);
+ }
+
+ $style = $sheet->getStyle('A1:I1');
+ $style->getFont()->setBold(true)->setSize(10)->getColor()->setRGB(self::COLOR_HEADER_TEXT);
+ $style->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setRGB(self::COLOR_HEADER_BG);
+ $style->getAlignment()->setVertical(Alignment::VERTICAL_CENTER);
+ $style->getBorders()->getBottom()->setBorderStyle(Border::BORDER_THIN)->getColor()->setRGB(self::COLOR_BORDER);
+ $sheet->getRowDimension(1)->setRowHeight(28);
+ }
+
+ private function excelWriteData(Worksheet $sheet, array $rows): int
+ {
+ $yes = $this->t('app.report.export_yes');
+ $no = $this->t('app.report.export_no');
+ $row = 2;
+
+ foreach ($rows as $r) {
+ $sheet->setCellValue("A{$row}", Date::dateTimeToExcel($r['date']));
+ $sheet->setCellValue("B{$row}", $r['client']);
+ $sheet->setCellValue("C{$row}", $r['project']);
+ $sheet->setCellValue("D{$row}", $r['service']);
+ $sheet->setCellValue("E{$row}", $r['user']);
+ $sheet->setCellValue("F{$row}", $r['note']);
+ $sheet->setCellValue("G{$row}", $r['hours']);
+
+ if ($r['revenue'] !== null) {
+ $sheet->setCellValue("H{$row}", $r['revenue']);
+ }
+
+ $sheet->setCellValue("I{$row}", $r['invoiced'] ? $yes : $no);
+
+ if ($row % 2 === 1) {
+ $sheet->getStyle("A{$row}:I{$row}")
+ ->getFill()->setFillType(Fill::FILL_SOLID)
+ ->getStartColor()->setRGB(self::COLOR_STRIPE);
+ }
+
+ $sheet->getRowDimension($row)->setRowHeight(22);
+ $row++;
+ }
+
+ return $row - 1;
+ }
+
+ private function excelWriteSummary(Worksheet $sheet, array $data, int $summaryRow): void
+ {
+ if (empty($data['rows'])) {
+ return;
+ }
+
+ $sheet->setCellValue("F{$summaryRow}", $this->t('app.report.export_sum'));
+ $sheet->setCellValue("G{$summaryRow}", $data['totalHours']);
+ $sheet->setCellValue("H{$summaryRow}", $data['totalRevenue']);
+
+ $style = $sheet->getStyle("A{$summaryRow}:I{$summaryRow}");
+ $style->getFont()->setBold(true)->setSize(10);
+ $style->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setRGB(self::COLOR_HEADER_BG);
+ $style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN)->getColor()->setRGB(self::COLOR_BORDER);
+ $sheet->getStyle("F{$summaryRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
+ $sheet->getRowDimension($summaryRow)->setRowHeight(28);
+ }
+
+ private function excelApplyStyles(Worksheet $sheet, int $lastDataRow): void
+ {
+ if ($lastDataRow < 2) {
+ return;
+ }
+
+ $dataRange = "A2:I{$lastDataRow}";
+ $sheet->getStyle($dataRange)->getFont()->setSize(10);
+ $sheet->getStyle($dataRange)->getAlignment()->setVertical(Alignment::VERTICAL_CENTER);
+ $sheet->getStyle($dataRange)->getBorders()->getBottom()
+ ->setBorderStyle(Border::BORDER_HAIR)->getColor()->setRGB(self::COLOR_BORDER);
+
+ $sheet->getStyle("A2:A{$lastDataRow}")->getNumberFormat()->setFormatCode('DD.MM.YYYY');
+
+ $sheet->getStyle("G2:G{$lastDataRow}")->getNumberFormat()->setFormatCode('#,##0.00');
+ $sheet->getStyle("G2:G{$lastDataRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
+
+ $summaryRow = $lastDataRow + 1;
+ $revenueRange = "H2:H{$summaryRow}";
+ $sheet->getStyle($revenueRange)->getNumberFormat()->setFormatCode('#,##0.00\ "€"');
+ $sheet->getStyle($revenueRange)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
+
+ $sheet->getStyle("G{$summaryRow}")->getNumberFormat()->setFormatCode('#,##0.00');
+ $sheet->getStyle("G{$summaryRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
+
+ $sheet->getStyle("I2:I{$lastDataRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
+
+ $sheet->setAutoFilter("A1:I{$lastDataRow}");
+ $sheet->freezePane('A2');
+ }
+
+ // ── CSV ──────────────────────────────────────────────────────────────────
+
+ /**
+ * @param TimeEntry[] $entries
+ * @param array $userMap
+ */
+ public function generateCsv(array $entries, array $userMap): string
+ {
+ $data = $this->prepareData($entries, $userMap);
+ $yes = $this->t('app.report.export_yes');
+ $no = $this->t('app.report.export_no');
+ $tmpFile = tempnam(sys_get_temp_dir(), 'report_') . '.csv';
+ $handle = fopen($tmpFile, 'w');
+
+ fwrite($handle, "\xEF\xBB\xBF");
+ fputcsv($handle, $this->headers(), ';');
+
+ foreach ($data['rows'] as $r) {
+ fputcsv($handle, [
+ $r['date']->format('d.m.Y'),
+ $r['client'],
+ $r['project'],
+ $r['service'],
+ $r['user'],
+ $r['note'],
+ number_format($r['hours'], 2, ',', ''),
+ $r['revenue'] !== null ? number_format($r['revenue'], 2, ',', '') : '',
+ $r['invoiced'] ? $yes : $no,
+ ], ';');
+ }
+
+ if (!empty($data['rows'])) {
+ fputcsv($handle, [
+ '', '', '', '', '', $this->t('app.report.export_sum'),
+ number_format($data['totalHours'], 2, ',', ''),
+ number_format($data['totalRevenue'], 2, ',', ''),
+ '',
+ ], ';');
+ }
+
+ fclose($handle);
+
+ return $tmpFile;
+ }
+
+ // ── PDF ──────────────────────────────────────────────────────────────────
+
+ /**
+ * @param TimeEntry[] $entries
+ * @param array $userMap
+ */
+ public function generatePdf(array $entries, array $userMap, string $accountName): string
+ {
+ $data = $this->prepareData($entries, $userMap);
+ $html = $this->buildPdfHtml($data, $accountName);
+
+ $options = new Options();
+ $options->setDefaultFont('Helvetica');
+ $options->setIsRemoteEnabled(false);
+
+ $dompdf = new Dompdf($options);
+ $dompdf->loadHtml($html);
+ $dompdf->setPaper('A4', 'landscape');
+ $dompdf->render();
+
+ $tmpFile = tempnam(sys_get_temp_dir(), 'report_') . '.pdf';
+ file_put_contents($tmpFile, $dompdf->output());
+
+ return $tmpFile;
+ }
+
+ private function buildPdfHtml(array $data, string $accountName): string
+ {
+ $date = date('d.m.Y');
+ $title = $this->t('app.report.export_title', ['%account%' => $accountName]);
+ $created = $this->t('app.report.export_created_at', ['%date%' => $date]);
+ $count = count($data['rows']);
+ $countLabel = $count === 1
+ ? $this->t('app.report.export_entry_count_one')
+ : $this->t('app.report.export_entry_count', ['%count%' => $count]);
+
+ $headers = $this->headers();
+ $invoicedTh = $this->t('app.report.export_col_invoiced_short');
+ $sumLabel = $this->t('app.report.export_sum');
+ $yes = $this->t('app.report.export_yes');
+ $no = $this->t('app.report.export_no');
+
+ $thHtml = '' . htmlspecialchars($headers[0]) . ' | '
+ . '' . htmlspecialchars($headers[1]) . ' | '
+ . '' . htmlspecialchars($headers[2]) . ' | '
+ . '' . htmlspecialchars($headers[3]) . ' | '
+ . '' . htmlspecialchars($headers[4]) . ' | '
+ . '' . htmlspecialchars($headers[5]) . ' | '
+ . '' . htmlspecialchars($headers[6]) . ' | '
+ . '' . htmlspecialchars($headers[7]) . ' | '
+ . '' . htmlspecialchars($invoicedTh) . ' | ';
+
+ $rowsHtml = '';
+ foreach ($data['rows'] as $i => $r) {
+ $stripe = $i % 2 === 1 ? ' style="background:#f7f9fc"' : '';
+ $revenue = $r['revenue'] !== null ? number_format($r['revenue'], 2, ',', '.') . ' €' : '';
+ $hours = number_format($r['hours'], 2, ',', '.');
+
+ $rowsHtml .= ""
+ . '| ' . htmlspecialchars($r['date']->format('d.m.Y')) . ' | '
+ . '' . htmlspecialchars($r['client']) . ' | '
+ . '' . htmlspecialchars($r['project']) . ' | '
+ . '' . htmlspecialchars($r['service']) . ' | '
+ . '' . htmlspecialchars($r['user']) . ' | '
+ . '' . htmlspecialchars($r['note']) . ' | '
+ . '' . $hours . ' | '
+ . '' . $revenue . ' | '
+ . '' . ($r['invoiced'] ? $yes : $no) . ' | '
+ . '
';
+ }
+
+ $totalHours = number_format($data['totalHours'], 2, ',', '.');
+ $totalRevenue = number_format($data['totalRevenue'], 2, ',', '.') . ' €';
+ $sumLabel = htmlspecialchars($sumLabel);
+
+ return <<
+
+
+
+
+
+
+
+
+ {$thHtml}
+
+ {$rowsHtml}
+
+ | {$sumLabel} |
+ {$totalHours} |
+ {$totalRevenue} |
+ |
+
+
+
+
+
+
+HTML;
+ }
+}
diff --git a/httpdocs/src/Twig/Runtime/AppExtensionRuntime.php b/httpdocs/src/Twig/Runtime/AppExtensionRuntime.php
deleted file mode 100644
index eb9e189..0000000
--- a/httpdocs/src/Twig/Runtime/AppExtensionRuntime.php
+++ /dev/null
@@ -1,18 +0,0 @@
-
+
+
+
+
diff --git a/httpdocs/templates/_atoms/icon-excel.html.twig b/httpdocs/templates/_atoms/icon-excel.html.twig
new file mode 100644
index 0000000..ac6df9e
--- /dev/null
+++ b/httpdocs/templates/_atoms/icon-excel.html.twig
@@ -0,0 +1,5 @@
+
diff --git a/httpdocs/templates/_atoms/icon-pdf.html.twig b/httpdocs/templates/_atoms/icon-pdf.html.twig
new file mode 100644
index 0000000..c36bbbb
--- /dev/null
+++ b/httpdocs/templates/_atoms/icon-pdf.html.twig
@@ -0,0 +1,5 @@
+
diff --git a/httpdocs/templates/_atoms/icon-print.html.twig b/httpdocs/templates/_atoms/icon-print.html.twig
new file mode 100644
index 0000000..ac829a2
--- /dev/null
+++ b/httpdocs/templates/_atoms/icon-print.html.twig
@@ -0,0 +1,6 @@
+
diff --git a/httpdocs/templates/_components/register-success.html.twig b/httpdocs/templates/_components/register-success.html.twig
index 308078a..6220845 100644
--- a/httpdocs/templates/_components/register-success.html.twig
+++ b/httpdocs/templates/_components/register-success.html.twig
@@ -10,5 +10,5 @@
{{ icon }}
{{ title }}
{{ text|raw }}
- {{ btn_label }}
+ {{ btn_label }}
diff --git a/httpdocs/templates/_macros/helpers.html.twig b/httpdocs/templates/_macros/helpers.html.twig
new file mode 100644
index 0000000..35abc5e
--- /dev/null
+++ b/httpdocs/templates/_macros/helpers.html.twig
@@ -0,0 +1,15 @@
+{# templates/_macros/helpers.html.twig #}
+
+{% macro smart_date(currentDate, todayStr, tomorrowStr, yesterdayStr, months, weekdays) %}
+ {%- set activStr = currentDate|date('Y-m-d') -%}
+ {%- set monthName = months[currentDate|date('n') - 1] -%}
+ {%- if activStr == todayStr -%}
+ {{ 'app.date.today'|trans }}, {{ currentDate|date('j') }}. {{ monthName }}
+ {%- elseif activStr == tomorrowStr -%}
+ {{ 'app.date.tomorrow'|trans }}, {{ currentDate|date('j') }}. {{ monthName }}
+ {%- elseif activStr == yesterdayStr -%}
+ {{ 'app.date.yesterday'|trans }}, {{ currentDate|date('j') }}. {{ monthName }}
+ {%- else -%}
+ {{ weekdays[currentDate|date('N') - 1] }}, {{ currentDate|date('j') }}. {{ monthName }}
+ {%- endif -%}
+{% endmacro %}
diff --git a/httpdocs/templates/_sections/nav.html.twig b/httpdocs/templates/_sections/nav.html.twig
index 9a21ee4..fa3e88c 100644
--- a/httpdocs/templates/_sections/nav.html.twig
+++ b/httpdocs/templates/_sections/nav.html.twig
@@ -45,7 +45,7 @@
{# Hamburger-Navigation — nur im Minimal-Theme sichtbar (via CSS) #}