| @@ -26,7 +26,8 @@ | |||
| "Bash(npm init *)", | |||
| "Bash(npm install *)", | |||
| "Bash(NODE_TLS_REJECT_UNAUTHORIZED=0 node test.mjs)", | |||
| "Bash(open *)" | |||
| "Bash(open *)", | |||
| "Bash(ddev status *)" | |||
| ] | |||
| } | |||
| } | |||
| @@ -213,6 +213,55 @@ Optionales Freitext-Label pro Zeiteintrag zur Kategorisierung (z.B. Ticketnummer | |||
| - **Report-Tabelle**: Eigene sortierbare Spalte (`label`), Anzeige als Badge. | |||
| - **Report-Filter**: Label-Filter mit Mehrfachauswahl, Autocomplete und Negation (`labels_neg`). Filterlogik: `IN` (default) oder `NOT IN` (negiert). | |||
| ## Wochenansicht / Kalender | |||
| Hauptansicht der App unter `/week/{date}`. Zeigt Einträge für einen ausgewählten Tag, navigierbar über eine Wochenleiste und einen Monatskalender. | |||
| ### Architektur | |||
| - **Backend**: `TimeTrackingController::week()` (Seite) + diverse API-Endpunkte | |||
| - **Frontend**: `assets/scripts/calendar.js` (`WeekCalendar`-Klasse), `assets/scripts/entries.js` (`EntryManager`-Klasse) | |||
| - **Styles**: `_week-nav.scss`, `_month-calendar.scss`, `_entry-list.scss` | |||
| - **Templates**: `timetracking/week.html.twig`, `_sections/tt-header.html.twig`, `timetracking/_entry_row.html.twig` | |||
| ### Entkoppelte Wochennavigation | |||
| `calendar.js` trennt zwei Konzepte: | |||
| - **`selectedDate`**: Welcher Tag ausgewählt ist (Einträge werden für diesen Tag angezeigt) | |||
| - **`displayMonday`**: Welche Woche in der Wochenleiste angezeigt wird | |||
| Beim Wochenwechsel (Pfeile) wird nur `displayMonday` geändert — Einträge bleiben sichtbar, kein Tag wird aktiv. Beim Klick auf einen Tag (Wochenleiste oder Monatskalender) wird `selectedDate` gesetzt und Einträge geladen. | |||
| Globale Instanz: `window.weekCalendar` — wird von `entries.js` für `refreshAfterMove()` genutzt. | |||
| ### Eintrags-Dot-Indikatoren | |||
| Tage mit mindestens einem Eintrag zeigen einen kleinen farbigen Punkt: | |||
| - **Wochenleiste**: `.week-nav__day-dot` (5px, unter dem Datum) | |||
| - **Monatskalender**: `.month-day__dot` (4px, absolut positioniert) | |||
| Daten kommen von `GET /api/entries/day-counts`. Mouseover auf einen Tag zeigt die Gesamt-Stundenzahl als nativen Tooltip (`title`-Attribut, Format `h:mm`). | |||
| ### Drag & Drop (Einträge verschieben) | |||
| Nicht-abgerechnete Einträge haben ein Grip-Icon (6 Punkte, `.entry-row__btn--move`) hinter dem Delete-Button. Reihenfolge der Actions: Badge → Stoppuhr → Edit → Delete → **Move-Handle**. | |||
| - **Drag-Start**: Nur vom Move-Handle aus (`draggable="true"` auf dem Button), `setDragImage` zeigt die Entry-Row | |||
| - **Drop-Target**: Jeder Tag in der Wochenleiste (`.week-nav__day--drop-target` als visuelles Feedback) | |||
| - **Move-API**: `PATCH /api/entries/{id}/move` mit `{ date: "YYYY-MM-DD" }` — verschiebt den Eintrag, prüft Tageslimit (1440 Min), blockiert abgerechnete Einträge (403) | |||
| - **Nach Move**: Entry wird animiert aus der Liste entfernt, Dot-Indikatoren werden aktualisiert | |||
| ### API-Endpunkte | |||
| | Route | Method | Beschreibung | | |||
| |------------------------------|--------|-------------------------------------------------------------| | |||
| | `/api/entries` | GET | Einträge für einen Tag: `?date=YYYY-MM-DD` | | |||
| | `/api/entries` | POST | Eintrag erstellen | | |||
| | `/api/entries/{id}` | PATCH | Eintrag bearbeiten (Duration, Projekt, Service, Label, Note)| | |||
| | `/api/entries/{id}` | DELETE | Eintrag löschen | | |||
| | `/api/entries/{id}/move` | PATCH | Eintrag auf anderen Tag verschieben: `{ date: "YYYY-MM-DD" }` | | |||
| | `/api/entries/day-counts` | GET | Einträge pro Tag zählen: `?from=...&to=...` → `{ "YYYY-MM-DD": { count, minutes } }` | | |||
| ## Stoppuhr / Timer | |||
| Live-Timer zum Tracken von Zeiteinträgen. UI in der Navigation (Desktop + Hamburger-Menü), zusätzlich Play-Button an jeder Entry-Row zum Fortsetzen. | |||
| @@ -13,15 +13,21 @@ class WeekCalendar { | |||
| this.nextBtn = document.querySelector('.week-nav__arrow--next'); | |||
| this.header = document.querySelector('.tt-header'); | |||
| const raw = this.nav?.dataset.activeDate; | |||
| this.activeDate = raw ? new Date(raw + 'T00:00:00') : new Date(); | |||
| this.today = new Date(); | |||
| const raw = this.nav?.dataset.activeDate; | |||
| this.selectedDate = raw ? new Date(raw + 'T00:00:00') : new Date(); | |||
| this.selectedDate.setHours(0, 0, 0, 0); | |||
| this.displayMonday = this.getMonday(this.selectedDate); | |||
| this.today = new Date(); | |||
| this.today.setHours(0, 0, 0, 0); | |||
| this.monthOpen = false; | |||
| this.monthDate = new Date(this.activeDate); | |||
| this.monthDate = new Date(this.selectedDate); | |||
| this.monthEl = null; | |||
| this.dayCounts = {}; | |||
| if (!this.nav) return; | |||
| this.init(); | |||
| } | |||
| @@ -43,6 +49,8 @@ class WeekCalendar { | |||
| const dateStr = dayEl.dataset.date; | |||
| if (dateStr) this.goToDate(new Date(dateStr + 'T00:00:00')); | |||
| }); | |||
| this.fetchDayCounts(this.displayMonday); | |||
| } | |||
| // ── Wochen-Navigation ───────────────────────────────────────────────────── | |||
| @@ -54,11 +62,12 @@ class WeekCalendar { | |||
| this.daysContainer.classList.add(slideOut); | |||
| setTimeout(() => { | |||
| const newDate = new Date(this.activeDate); | |||
| newDate.setDate(newDate.getDate() + direction * 7); | |||
| this.activeDate = newDate; | |||
| const newMonday = new Date(this.displayMonday); | |||
| newMonday.setDate(newMonday.getDate() + direction * 7); | |||
| this.displayMonday = newMonday; | |||
| this.renderWeekDays(); | |||
| this.updateHeaderMeta(); | |||
| this.updateWeekLabel(); | |||
| this.daysContainer.classList.remove(slideOut); | |||
| this.daysContainer.classList.add(slideIn); | |||
| @@ -66,74 +75,193 @@ class WeekCalendar { | |||
| this.daysContainer.classList.remove(slideIn); | |||
| })); | |||
| window.history.pushState({}, '', `/week/${this.formatDate(this.getMonday(this.activeDate))}`); | |||
| window.entryManager?.loadEntriesForDate(this.formatDate(this.activeDate)); | |||
| window.history.pushState({}, '', `/week/${this.formatDate(this.displayMonday)}`); | |||
| this.fetchDayCounts(this.displayMonday); | |||
| }, FADE_MS); | |||
| } | |||
| renderWeekDays() { | |||
| const monday = this.getMonday(this.activeDate); | |||
| this.daysContainer.innerHTML = ''; | |||
| for (let i = 0; i < 7; i++) { | |||
| const d = new Date(monday); | |||
| const d = new Date(this.displayMonday); | |||
| d.setDate(d.getDate() + i); | |||
| const isActive = this.isSameDay(d, this.activeDate); | |||
| const isToday = this.isSameDay(d, this.today); | |||
| const isSelected = this.isSameDay(d, this.selectedDate); | |||
| const isToday = this.isSameDay(d, this.today); | |||
| const dateStr = this.formatDate(d); | |||
| const dayData = this.dayCounts[dateStr]; | |||
| const hasEntries = dayData && dayData.count > 0; | |||
| const dayNum = String(d.getDate()).padStart(2, '0'); | |||
| const monthShort = this.monthsShort[d.getMonth()] ?? ''; | |||
| const a = document.createElement('a'); | |||
| a.href = `/week/${this.formatDate(d)}`; | |||
| a.href = `/week/${dateStr}`; | |||
| a.className = 'week-nav__day' | |||
| + (isActive ? ' week-nav__day--active' : '') | |||
| + (isToday ? ' week-nav__day--today' : ''); | |||
| a.dataset.date = this.formatDate(d); | |||
| + (isSelected ? ' week-nav__day--active' : '') | |||
| + (isToday ? ' week-nav__day--today' : ''); | |||
| a.dataset.date = dateStr; | |||
| if (hasEntries) a.title = this.formatMinutes(dayData.minutes); | |||
| a.innerHTML = ` | |||
| <span class="week-nav__day-name">${esc(this.weekdaysShort[i] ?? '')}</span> | |||
| <span class="week-nav__day-date">${dayNum}. ${esc(monthShort)}</span> | |||
| ${hasEntries ? '<span class="week-nav__day-dot"></span>' : ''} | |||
| `; | |||
| this.daysContainer.appendChild(a); | |||
| } | |||
| this.initDropTargets(); | |||
| } | |||
| goToDate(date) { | |||
| this.activeDate = date; | |||
| this.selectedDate = date; | |||
| this.displayMonday = this.getMonday(date); | |||
| this.renderWeekDays(); | |||
| this.updateHeaderMeta(); | |||
| window.history.pushState({}, '', `/week/${this.formatDate(date)}`); | |||
| window.entryManager?.loadEntriesForDate(this.formatDate(date)); | |||
| if (this.monthOpen) this.closeMonth(); | |||
| this.fetchDayCounts(this.displayMonday); | |||
| } | |||
| updateHeaderMeta() { | |||
| this.updateDateDisplay(); | |||
| this.updateWeekLabel(); | |||
| } | |||
| updateDateDisplay() { | |||
| const dateEl = document.querySelector('.tt-header__date'); | |||
| const kwEl = document.querySelector('.tt-header__kw'); | |||
| if (dateEl) { | |||
| const d = this.activeDate; | |||
| const day = d.getDate(); | |||
| const month = this.months[d.getMonth()] ?? ''; | |||
| const tomorrow = new Date(this.today); tomorrow.setDate(this.today.getDate() + 1); | |||
| const yesterday = new Date(this.today); yesterday.setDate(this.today.getDate() - 1); | |||
| const jsDay = d.getDay(); | |||
| const isoIdx = jsDay === 0 ? 6 : jsDay - 1; | |||
| const weekday = this.weekdays[isoIdx] ?? ''; | |||
| let prefix; | |||
| if (this.isSameDay(d, this.today)) prefix = t('today'); | |||
| else if (this.isSameDay(d, tomorrow)) prefix = t('tomorrow'); | |||
| else if (this.isSameDay(d, yesterday)) prefix = t('yesterday'); | |||
| else prefix = weekday; | |||
| dateEl.textContent = `${prefix}, ${day}. ${month}`; | |||
| document.title = `${prefix}, ${day}. ${month}`; | |||
| } | |||
| if (!dateEl) return; | |||
| const d = this.selectedDate; | |||
| const day = d.getDate(); | |||
| const month = this.months[d.getMonth()] ?? ''; | |||
| const tomorrow = new Date(this.today); tomorrow.setDate(this.today.getDate() + 1); | |||
| const yesterday = new Date(this.today); yesterday.setDate(this.today.getDate() - 1); | |||
| const jsDay = d.getDay(); | |||
| const isoIdx = jsDay === 0 ? 6 : jsDay - 1; | |||
| const weekday = this.weekdays[isoIdx] ?? ''; | |||
| let prefix; | |||
| if (this.isSameDay(d, this.today)) prefix = t('today'); | |||
| else if (this.isSameDay(d, tomorrow)) prefix = t('tomorrow'); | |||
| else if (this.isSameDay(d, yesterday)) prefix = t('yesterday'); | |||
| else prefix = weekday; | |||
| dateEl.textContent = `${prefix}, ${day}. ${month}`; | |||
| document.title = `${prefix}, ${day}. ${month}`; | |||
| } | |||
| updateWeekLabel() { | |||
| const kwEl = document.querySelector('.tt-header__kw'); | |||
| const weekNum = this.getWeekNumber(this.displayMonday); | |||
| if (kwEl) kwEl.textContent = `${t('weekLabel')} ${weekNum}`; | |||
| window._updateWeekToggle?.(weekNum); | |||
| } | |||
| // ── Dot-Indikatoren ─────────────────────────────────────────────────────── | |||
| async fetchDayCounts(monday) { | |||
| const sunday = new Date(monday); | |||
| sunday.setDate(sunday.getDate() + 6); | |||
| try { | |||
| const res = await fetch(`/api/entries/day-counts?from=${this.formatDate(monday)}&to=${this.formatDate(sunday)}`); | |||
| if (!res.ok) return; | |||
| const counts = await res.json(); | |||
| Object.assign(this.dayCounts, counts); | |||
| this.applyDots(); | |||
| } catch { /* silent */ } | |||
| } | |||
| async fetchMonthDayCounts(year, month) { | |||
| const from = new Date(year, month, 1); | |||
| const to = new Date(year, month + 1, 0); | |||
| try { | |||
| const res = await fetch(`/api/entries/day-counts?from=${this.formatDate(from)}&to=${this.formatDate(to)}`); | |||
| if (!res.ok) return; | |||
| const counts = await res.json(); | |||
| Object.assign(this.dayCounts, counts); | |||
| this.applyMonthDots(); | |||
| } catch { /* silent */ } | |||
| } | |||
| if (kwEl) kwEl.textContent = `${t('weekLabel')} ${this.getWeekNumber(this.activeDate)}`; | |||
| window._updateWeekToggle?.(this.getWeekNumber(this.activeDate)); | |||
| applyDots() { | |||
| this.daysContainer?.querySelectorAll('.week-nav__day').forEach(dayEl => { | |||
| const dateStr = dayEl.dataset.date; | |||
| const dayData = this.dayCounts[dateStr]; | |||
| const hasEntries = dayData && dayData.count > 0; | |||
| const existing = dayEl.querySelector('.week-nav__day-dot'); | |||
| if (hasEntries) { | |||
| dayEl.title = this.formatMinutes(dayData.minutes); | |||
| if (!existing) { | |||
| const dot = document.createElement('span'); | |||
| dot.className = 'week-nav__day-dot'; | |||
| dayEl.appendChild(dot); | |||
| } | |||
| } else { | |||
| dayEl.removeAttribute('title'); | |||
| if (existing) existing.remove(); | |||
| } | |||
| }); | |||
| } | |||
| applyMonthDots() { | |||
| if (!this.monthEl) return; | |||
| this.monthEl.querySelectorAll('.month-day[data-date]').forEach(el => { | |||
| const dateStr = el.dataset.date; | |||
| const dayData = this.dayCounts[dateStr]; | |||
| const hasEntries = dayData && dayData.count > 0; | |||
| const existing = el.querySelector('.month-day__dot'); | |||
| if (hasEntries) { | |||
| el.title = this.formatMinutes(dayData.minutes); | |||
| if (!existing) { | |||
| const dot = document.createElement('span'); | |||
| dot.className = 'month-day__dot'; | |||
| el.appendChild(dot); | |||
| } | |||
| } else { | |||
| el.removeAttribute('title'); | |||
| if (existing) existing.remove(); | |||
| } | |||
| }); | |||
| } | |||
| // ── Drop-Targets auf Wochentagen ────────────────────────────────────────── | |||
| initDropTargets() { | |||
| this.daysContainer?.querySelectorAll('.week-nav__day').forEach(dayEl => { | |||
| dayEl.addEventListener('dragover', e => { | |||
| e.preventDefault(); | |||
| e.dataTransfer.dropEffect = 'move'; | |||
| dayEl.classList.add('week-nav__day--drop-target'); | |||
| }); | |||
| dayEl.addEventListener('dragleave', () => { | |||
| dayEl.classList.remove('week-nav__day--drop-target'); | |||
| }); | |||
| dayEl.addEventListener('drop', e => { | |||
| e.preventDefault(); | |||
| dayEl.classList.remove('week-nav__day--drop-target'); | |||
| const entryId = e.dataTransfer.getData('text/entry-id'); | |||
| const targetDate = dayEl.dataset.date; | |||
| if (entryId && targetDate) { | |||
| window.entryManager?.moveEntry(parseInt(entryId, 10), targetDate); | |||
| } | |||
| }); | |||
| }); | |||
| } | |||
| refreshAfterMove() { | |||
| this.fetchDayCounts(this.displayMonday); | |||
| if (this.monthOpen) { | |||
| this.fetchMonthDayCounts(this.monthDate.getFullYear(), this.monthDate.getMonth()); | |||
| } | |||
| } | |||
| // ── Monats-Ansicht ──────────────────────────────────────────────────────── | |||
| @@ -142,7 +270,7 @@ class WeekCalendar { | |||
| openMonth() { | |||
| this.monthOpen = true; | |||
| this.monthDate = new Date(this.activeDate); | |||
| this.monthDate = new Date(this.selectedDate); | |||
| this.monthEl = document.createElement('div'); | |||
| this.monthEl.className = 'month-calendar month-calendar--hidden'; | |||
| @@ -157,6 +285,8 @@ class WeekCalendar { | |||
| this.monthEl.classList.add('month-calendar--visible'); | |||
| })); | |||
| this.calBtn.classList.add('week-nav__cal--active'); | |||
| this.fetchMonthDayCounts(this.monthDate.getFullYear(), this.monthDate.getMonth()); | |||
| } | |||
| closeMonth() { | |||
| @@ -214,7 +344,7 @@ class WeekCalendar { | |||
| for (let d = 1; d <= daysInMonth; d++) { | |||
| const date = new Date(year, month, d); | |||
| const isToday = this.isSameDay(date, this.today); | |||
| const isActive= this.isSameDay(date, this.activeDate); | |||
| const isActive= this.isSameDay(date, this.selectedDate); | |||
| const cls = 'month-day' | |||
| + (isToday ? ' month-day--today' : '') | |||
| + (isActive ? ' month-day--active' : ''); | |||
| @@ -234,6 +364,8 @@ class WeekCalendar { | |||
| this.monthEl.querySelectorAll('.month-day[data-date]').forEach(el => { | |||
| el.addEventListener('click', () => this.goToDate(new Date(el.dataset.date + 'T00:00:00'))); | |||
| }); | |||
| this.applyMonthDots(); | |||
| } | |||
| navigateMonth(direction) { | |||
| @@ -244,6 +376,7 @@ class WeekCalendar { | |||
| setTimeout(() => { | |||
| this.monthDate.setMonth(this.monthDate.getMonth() + direction); | |||
| this.renderMonthGrid(); | |||
| this.fetchMonthDayCounts(this.monthDate.getFullYear(), this.monthDate.getMonth()); | |||
| }, 160); | |||
| } | |||
| @@ -270,6 +403,10 @@ class WeekCalendar { | |||
| return `${y}-${m}-${d}`; | |||
| } | |||
| formatMinutes(minutes) { | |||
| return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`; | |||
| } | |||
| getWeekNumber(date) { | |||
| const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); | |||
| d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); | |||
| @@ -278,4 +415,8 @@ class WeekCalendar { | |||
| } | |||
| } | |||
| document.addEventListener('DOMContentLoaded', () => { new WeekCalendar(); }); | |||
| let weekCalendar = null; | |||
| document.addEventListener('DOMContentLoaded', () => { | |||
| weekCalendar = new WeekCalendar(); | |||
| window.weekCalendar = weekCalendar; | |||
| }); | |||
| @@ -9,6 +9,7 @@ const LAST_SERVICE_KEY = 'tt_last_service_id'; | |||
| const NOTE_KEY = 'tt_minimal_note_open'; | |||
| const LOCK_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="3" y="7.5" width="10" height="7" rx="1.5" stroke="currentColor" stroke-width="1.3"/><path d="M5.5 7.5V5.5a2.5 2.5 0 015 0v2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`; | |||
| const MOVE_SVG = `<svg viewBox="0 0 16 16" fill="none"><circle cx="6" cy="3.5" r="1.2" fill="currentColor"/><circle cx="10" cy="3.5" r="1.2" fill="currentColor"/><circle cx="6" cy="8" r="1.2" fill="currentColor"/><circle cx="10" cy="8" r="1.2" fill="currentColor"/><circle cx="6" cy="12.5" r="1.2" fill="currentColor"/><circle cx="10" cy="12.5" r="1.2" fill="currentColor"/></svg>`; | |||
| const t = createTranslator('TT'); | |||
| @@ -75,6 +76,9 @@ function buildEntryRowHTML(entry, animate = false) { | |||
| </button> | |||
| <button class="entry-row__btn entry-row__btn--delete" title="${t('btnDelete')}" data-action="delete"> | |||
| <svg viewBox="0 0 16 16" fill="none"><path d="M3 4h10M6 4V2h4v2M5 4l.5 9h5l.5-9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg> | |||
| </button> | |||
| <button class="entry-row__btn entry-row__btn--move" title="${t('btnMove')}" data-action="move" draggable="true"> | |||
| ${MOVE_SVG} | |||
| </button>`; | |||
| const editFormHtml = invoiced ? '' : ` | |||
| @@ -189,6 +193,8 @@ class EntryManager { | |||
| }); | |||
| this.list.addEventListener('click', e => this.handleListClick(e)); | |||
| this.list.addEventListener('dragstart', e => this.handleDragStart(e)); | |||
| this.list.addEventListener('dragend', e => this.handleDragEnd(e)); | |||
| document.getElementById('btn-create')?.addEventListener('click', () => this.createEntry()); | |||
| this.checkAutoEdit(); | |||
| @@ -433,6 +439,63 @@ class EntryManager { | |||
| } | |||
| } | |||
| handleDragStart(e) { | |||
| const moveBtn = e.target.closest('[data-action="move"]'); | |||
| if (!moveBtn) { e.preventDefault(); return; } | |||
| const row = moveBtn.closest('.entry-row'); | |||
| if (!row || row.dataset.invoiced === 'true') { e.preventDefault(); return; } | |||
| e.dataTransfer.effectAllowed = 'move'; | |||
| e.dataTransfer.setData('text/entry-id', row.dataset.id); | |||
| const display = row.querySelector('.entry-row__display'); | |||
| if (display) e.dataTransfer.setDragImage(display, 20, 20); | |||
| row.classList.add('entry-row--dragging'); | |||
| } | |||
| handleDragEnd(e) { | |||
| const row = e.target.closest('.entry-row'); | |||
| if (row) row.classList.remove('entry-row--dragging'); | |||
| document.querySelectorAll('.week-nav__day--drop-target').forEach(el => { | |||
| el.classList.remove('week-nav__day--drop-target'); | |||
| }); | |||
| } | |||
| async moveEntry(entryId, targetDate) { | |||
| const row = document.getElementById(`entry-${entryId}`); | |||
| if (!row) return; | |||
| try { | |||
| const res = await fetch(`/api/entries/${entryId}/move`, { | |||
| method: 'PATCH', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify({ date: targetDate }), | |||
| }); | |||
| if (!res.ok) { | |||
| const err = await res.json().catch(() => ({})); | |||
| alert(t('errorMove') + (err.error ? `\n${err.error}` : '')); | |||
| return; | |||
| } | |||
| const data = await res.json(); | |||
| if (data.entry.date !== window.TT.activeDate) { | |||
| removeWithAnimation(row, 'entry-row--removing'); | |||
| setTimeout(() => { | |||
| this.updateTotal(data.oldDateTotal); | |||
| this.checkIfEmpty(); | |||
| }, ANIMATION_MS); | |||
| } | |||
| window.weekCalendar?.refreshAfterMove(); | |||
| } catch { | |||
| alert(t('errorMove')); | |||
| } | |||
| } | |||
| async loadEntriesForDate(dateStr) { | |||
| window.TT.activeDate = dateStr; | |||
| @@ -27,7 +27,7 @@ | |||
| .entry-list__footer { | |||
| display: flex; | |||
| justify-content: flex-end; | |||
| padding: $space-3 calc(#{$space-8} + #{$icon-btn-size} + #{$icon-btn-size} + #{$space-2} + #{$space-2}); | |||
| padding: $space-3 calc(#{$space-8} + #{$icon-btn-size} * 3 + #{$space-2} * 3); | |||
| border-top: 1px solid $color-border; | |||
| @include tablet { padding: $space-3 $space-4; } | |||
| @@ -140,17 +140,25 @@ | |||
| &--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; } | |||
| &--move { cursor: grab; } | |||
| &--move:hover { background: rgba(var(--color-primary-rgb), 0.1); color: var(--color-primary); } | |||
| &--move:active { cursor: grabbing; } | |||
| @media (hover: none) { opacity: 1; } | |||
| @include tablet { opacity: 1; } | |||
| } | |||
| // ─── Dragging-State ────────────────────────────────────────────────────── | |||
| .entry-row--dragging { | |||
| opacity: 0.4; | |||
| } | |||
| // ─── Lock-Indikator (invoiced) ──────────────────────────────────────────── | |||
| .entry-row__lock-indicator { | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| width: calc(#{$icon-btn-size} + #{$space-2} + #{$icon-btn-size}); | |||
| width: calc(#{$icon-btn-size} * 3 + #{$space-2} * 2); | |||
| flex-shrink: 0; | |||
| color: $color-text-dark; | |||
| @@ -92,6 +92,7 @@ | |||
| // ─── Einzelner Tag ─────────────────────────────────────────────────────────── | |||
| .month-day { | |||
| position: relative; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| @@ -128,3 +129,20 @@ | |||
| font-weight: $font-weight-bold; | |||
| } | |||
| } | |||
| .month-day__dot { | |||
| position: absolute; | |||
| bottom: 9px; | |||
| left: 50%; | |||
| transform: translateX(-50%); | |||
| width: 4px; | |||
| height: 4px; | |||
| border-radius: 50%; | |||
| background: var(--header-text); | |||
| opacity: 0.5; | |||
| .month-day--today & { | |||
| background: var(--color-primary); | |||
| opacity: 0.8; | |||
| } | |||
| } | |||
| @@ -101,6 +101,29 @@ | |||
| line-height: 1.3; | |||
| } | |||
| // ─── Eintrags-Dot ──────────────────────────────────────────────────────── | |||
| .week-nav__day-dot { | |||
| display: block; | |||
| width: 5px; | |||
| height: 5px; | |||
| border-radius: 50%; | |||
| background: var(--header-text); | |||
| margin-top: 3px; | |||
| opacity: 0.6; | |||
| .week-nav__day--active & { | |||
| background: var(--color-primary); | |||
| opacity: 1; | |||
| } | |||
| } | |||
| // ─── Drop-Target ───────────────────────────────────────────────────────── | |||
| .week-nav__day--drop-target { | |||
| background: rgba($color-white, 0.3) !important; | |||
| outline: 2px dashed rgba($color-white, 0.7); | |||
| outline-offset: -2px; | |||
| } | |||
| // ─── Kalender-Icon ─────────────────────────────────────────────────────────── | |||
| .week-nav__cal { | |||
| @include icon-btn(34px, $radius-md); | |||
| @@ -225,6 +225,73 @@ class TimeTrackingController extends AbstractController | |||
| return $this->json(['totalDuration' => $this->formatMinutes($totalMin)]); | |||
| } | |||
| // ── API: Eintrag verschieben ──────────────────────────────────────────────── | |||
| #[Route('/api/entries/{id}/move', name: 'api_entries_move', methods: ['PATCH'])] | |||
| public function apiMove(int $id, Request $request): JsonResponse | |||
| { | |||
| $entry = $this->timeEntryRepo->find($id); | |||
| if (!$entry) { | |||
| 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' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| if ($entry->isInvoiced()) { | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $data = json_decode($request->getContent(), true); | |||
| if (empty($data['date'])) { | |||
| return $this->json(['error' => $this->translator->trans('app.error.not_found')], 400); | |||
| } | |||
| $tz = new \DateTimeZone('Europe/Berlin'); | |||
| $newDate = new \DateTimeImmutable($data['date'], $tz); | |||
| $oldDate = $entry->getDate(); | |||
| if ($oldDate->format('Y-m-d') === $newDate->format('Y-m-d')) { | |||
| return $this->json(['entry' => $entry->toArray()]); | |||
| } | |||
| $targetTotal = $this->timeEntryRepo->sumDurationByDateAndUserId($newDate, $entry->getUserId()); | |||
| if ($targetTotal + $entry->getDuration() > 1440) { | |||
| return $this->json(['error' => $this->translator->trans('app.error.daily_limit')], 422); | |||
| } | |||
| $entry->setDate($newDate); | |||
| $this->tenantEm->flush(); | |||
| $oldTotal = $this->timeEntryRepo->sumDurationByDateAndUserId($oldDate, $entry->getUserId()); | |||
| $newTotal = $this->timeEntryRepo->sumDurationByDateAndUserId($newDate, $entry->getUserId()); | |||
| return $this->json([ | |||
| 'entry' => $entry->toArray(), | |||
| 'oldDateTotal' => $this->formatMinutes($oldTotal), | |||
| 'newDateTotal' => $this->formatMinutes($newTotal), | |||
| ]); | |||
| } | |||
| // ── API: Einträge pro Tag zählen ───────────────────────────────────────── | |||
| #[Route('/api/entries/day-counts', name: 'api_entries_day_counts', methods: ['GET'])] | |||
| public function apiDayCounts(Request $request): JsonResponse | |||
| { | |||
| $tz = new \DateTimeZone('Europe/Berlin'); | |||
| $from = new \DateTimeImmutable($request->query->get('from', 'monday this week'), $tz); | |||
| $to = new \DateTimeImmutable($request->query->get('to', 'sunday this week'), $tz); | |||
| /** @var User $user */ | |||
| $user = $this->getUser(); | |||
| $counts = $this->timeEntryRepo->countEntriesByDays($from, $to, $user->getId()); | |||
| return $this->json($counts); | |||
| } | |||
| // ── Labels-API ──────────────────────────────────────────────────────────── | |||
| #[Route('/api/labels', name: 'api_labels', methods: ['GET'])] | |||
| @@ -108,6 +108,7 @@ class TimeEntry | |||
| { | |||
| return [ | |||
| 'id' => $this->id, | |||
| 'date' => $this->date->format('Y-m-d'), | |||
| 'duration' => $this->duration, | |||
| 'durationFormatted' => $this->getDurationFormatted(), | |||
| 'projectId' => $this->project?->getId(), | |||
| @@ -46,6 +46,37 @@ class TimeEntryRepository extends ServiceEntityRepository | |||
| return (int) $result; | |||
| } | |||
| /** | |||
| * @return array<string, array{count: int, minutes: int}> | |||
| */ | |||
| public function countEntriesByDays(\DateTimeImmutable $from, \DateTimeImmutable $to, int $userId): array | |||
| { | |||
| $rows = $this->createQueryBuilder('t') | |||
| ->select('t.date AS day, COUNT(t.id) AS cnt, SUM(t.duration) AS totalMinutes') | |||
| ->where('t.date >= :from') | |||
| ->andWhere('t.date <= :to') | |||
| ->andWhere('t.userId = :userId') | |||
| ->setParameter('from', $from->format('Y-m-d')) | |||
| ->setParameter('to', $to->format('Y-m-d')) | |||
| ->setParameter('userId', $userId) | |||
| ->groupBy('t.date') | |||
| ->getQuery() | |||
| ->getResult(); | |||
| $counts = []; | |||
| foreach ($rows as $row) { | |||
| $dateStr = $row['day'] instanceof \DateTimeInterface | |||
| ? $row['day']->format('Y-m-d') | |||
| : $row['day']; | |||
| $counts[$dateStr] = [ | |||
| 'count' => (int) $row['cnt'], | |||
| 'minutes' => (int) $row['totalMinutes'], | |||
| ]; | |||
| } | |||
| return $counts; | |||
| } | |||
| public function findRunningTimerByUserId(int $userId): ?TimeEntry | |||
| { | |||
| return $this->createQueryBuilder('t') | |||
| @@ -44,6 +44,12 @@ | |||
| data-action="delete"> | |||
| {% include '_atoms/icon-delete.html.twig' %} | |||
| </button> | |||
| <button class="entry-row__btn entry-row__btn--move" | |||
| title="{{ 'app.entry.btn_move'|trans }}" | |||
| data-action="move" | |||
| draggable="true"> | |||
| <svg viewBox="0 0 16 16" fill="none"><circle cx="6" cy="3.5" r="1.2" fill="currentColor"/><circle cx="10" cy="3.5" r="1.2" fill="currentColor"/><circle cx="6" cy="8" r="1.2" fill="currentColor"/><circle cx="10" cy="8" r="1.2" fill="currentColor"/><circle cx="6" cy="12.5" r="1.2" fill="currentColor"/><circle cx="10" cy="12.5" r="1.2" fill="currentColor"/></svg> | |||
| </button> | |||
| {% endif %} | |||
| </div> | |||
| </div> | |||
| @@ -66,6 +66,9 @@ window.TT = { | |||
| errorDailyLimitExceeded: {{ 'app.entry.error_daily_limit_exceeded'|trans|json_encode|raw }}, | |||
| warnDurationLong: {{ 'app.entry.warn_duration_long'|trans|json_encode|raw }}, | |||
| invoicedTitle: {{ 'app.entry.invoiced_title'|trans|json_encode|raw }}, | |||
| btnMove: {{ 'app.entry.btn_move'|trans|json_encode|raw }}, | |||
| moveSuccess: {{ 'app.entry.move_success'|trans|json_encode|raw }}, | |||
| errorMove: {{ 'app.entry.error_move'|trans|json_encode|raw }}, | |||
| noteShow: {{ 'app.entry.note_show'|trans|json_encode|raw }}, | |||
| noteHide: {{ 'app.entry.note_hide'|trans|json_encode|raw }}, | |||
| btnTimerToggle: {{ 'app.stopwatch.resume'|trans|json_encode|raw }}, | |||
| @@ -94,6 +94,9 @@ app: | |||
| note_show: "+ Bemerkung hinzufügen" | |||
| note_hide: "× Bemerkung ausblenden" | |||
| invoiced_title: "Abgerechnet – Bearbeiten nicht möglich" | |||
| btn_move: "Auf anderen Tag verschieben" | |||
| move_success: "Eintrag verschoben" | |||
| error_move: "Fehler beim Verschieben des Eintrags." | |||
| count_one: "Eintrag" | |||
| count_other: "Einträge" | |||