| @@ -26,7 +26,8 @@ | |||||
| "Bash(npm init *)", | "Bash(npm init *)", | ||||
| "Bash(npm install *)", | "Bash(npm install *)", | ||||
| "Bash(NODE_TLS_REJECT_UNAUTHORIZED=0 node test.mjs)", | "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-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). | - **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 | ## 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. | 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.nextBtn = document.querySelector('.week-nav__arrow--next'); | ||||
| this.header = document.querySelector('.tt-header'); | 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.today.setHours(0, 0, 0, 0); | ||||
| this.monthOpen = false; | this.monthOpen = false; | ||||
| this.monthDate = new Date(this.activeDate); | |||||
| this.monthDate = new Date(this.selectedDate); | |||||
| this.monthEl = null; | this.monthEl = null; | ||||
| this.dayCounts = {}; | |||||
| if (!this.nav) return; | if (!this.nav) return; | ||||
| this.init(); | this.init(); | ||||
| } | } | ||||
| @@ -43,6 +49,8 @@ class WeekCalendar { | |||||
| const dateStr = dayEl.dataset.date; | const dateStr = dayEl.dataset.date; | ||||
| if (dateStr) this.goToDate(new Date(dateStr + 'T00:00:00')); | if (dateStr) this.goToDate(new Date(dateStr + 'T00:00:00')); | ||||
| }); | }); | ||||
| this.fetchDayCounts(this.displayMonday); | |||||
| } | } | ||||
| // ── Wochen-Navigation ───────────────────────────────────────────────────── | // ── Wochen-Navigation ───────────────────────────────────────────────────── | ||||
| @@ -54,11 +62,12 @@ class WeekCalendar { | |||||
| this.daysContainer.classList.add(slideOut); | this.daysContainer.classList.add(slideOut); | ||||
| setTimeout(() => { | 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.renderWeekDays(); | ||||
| this.updateHeaderMeta(); | |||||
| this.updateWeekLabel(); | |||||
| this.daysContainer.classList.remove(slideOut); | this.daysContainer.classList.remove(slideOut); | ||||
| this.daysContainer.classList.add(slideIn); | this.daysContainer.classList.add(slideIn); | ||||
| @@ -66,74 +75,193 @@ class WeekCalendar { | |||||
| this.daysContainer.classList.remove(slideIn); | 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); | }, FADE_MS); | ||||
| } | } | ||||
| renderWeekDays() { | renderWeekDays() { | ||||
| const monday = this.getMonday(this.activeDate); | |||||
| this.daysContainer.innerHTML = ''; | this.daysContainer.innerHTML = ''; | ||||
| for (let i = 0; i < 7; i++) { | for (let i = 0; i < 7; i++) { | ||||
| const d = new Date(monday); | |||||
| const d = new Date(this.displayMonday); | |||||
| d.setDate(d.getDate() + i); | 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 dayNum = String(d.getDate()).padStart(2, '0'); | ||||
| const monthShort = this.monthsShort[d.getMonth()] ?? ''; | const monthShort = this.monthsShort[d.getMonth()] ?? ''; | ||||
| const a = document.createElement('a'); | const a = document.createElement('a'); | ||||
| a.href = `/week/${this.formatDate(d)}`; | |||||
| a.href = `/week/${dateStr}`; | |||||
| a.className = 'week-nav__day' | 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 = ` | a.innerHTML = ` | ||||
| <span class="week-nav__day-name">${esc(this.weekdaysShort[i] ?? '')}</span> | <span class="week-nav__day-name">${esc(this.weekdaysShort[i] ?? '')}</span> | ||||
| <span class="week-nav__day-date">${dayNum}. ${esc(monthShort)}</span> | <span class="week-nav__day-date">${dayNum}. ${esc(monthShort)}</span> | ||||
| ${hasEntries ? '<span class="week-nav__day-dot"></span>' : ''} | |||||
| `; | `; | ||||
| this.daysContainer.appendChild(a); | this.daysContainer.appendChild(a); | ||||
| } | } | ||||
| this.initDropTargets(); | |||||
| } | } | ||||
| goToDate(date) { | goToDate(date) { | ||||
| this.activeDate = date; | |||||
| this.selectedDate = date; | |||||
| this.displayMonday = this.getMonday(date); | |||||
| this.renderWeekDays(); | this.renderWeekDays(); | ||||
| this.updateHeaderMeta(); | this.updateHeaderMeta(); | ||||
| window.history.pushState({}, '', `/week/${this.formatDate(date)}`); | window.history.pushState({}, '', `/week/${this.formatDate(date)}`); | ||||
| window.entryManager?.loadEntriesForDate(this.formatDate(date)); | window.entryManager?.loadEntriesForDate(this.formatDate(date)); | ||||
| if (this.monthOpen) this.closeMonth(); | if (this.monthOpen) this.closeMonth(); | ||||
| this.fetchDayCounts(this.displayMonday); | |||||
| } | } | ||||
| updateHeaderMeta() { | updateHeaderMeta() { | ||||
| this.updateDateDisplay(); | |||||
| this.updateWeekLabel(); | |||||
| } | |||||
| updateDateDisplay() { | |||||
| const dateEl = document.querySelector('.tt-header__date'); | 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 ──────────────────────────────────────────────────────── | // ── Monats-Ansicht ──────────────────────────────────────────────────────── | ||||
| @@ -142,7 +270,7 @@ class WeekCalendar { | |||||
| openMonth() { | openMonth() { | ||||
| this.monthOpen = true; | this.monthOpen = true; | ||||
| this.monthDate = new Date(this.activeDate); | |||||
| this.monthDate = new Date(this.selectedDate); | |||||
| this.monthEl = document.createElement('div'); | this.monthEl = document.createElement('div'); | ||||
| this.monthEl.className = 'month-calendar month-calendar--hidden'; | this.monthEl.className = 'month-calendar month-calendar--hidden'; | ||||
| @@ -157,6 +285,8 @@ class WeekCalendar { | |||||
| this.monthEl.classList.add('month-calendar--visible'); | this.monthEl.classList.add('month-calendar--visible'); | ||||
| })); | })); | ||||
| this.calBtn.classList.add('week-nav__cal--active'); | this.calBtn.classList.add('week-nav__cal--active'); | ||||
| this.fetchMonthDayCounts(this.monthDate.getFullYear(), this.monthDate.getMonth()); | |||||
| } | } | ||||
| closeMonth() { | closeMonth() { | ||||
| @@ -214,7 +344,7 @@ class WeekCalendar { | |||||
| for (let d = 1; d <= daysInMonth; d++) { | for (let d = 1; d <= daysInMonth; d++) { | ||||
| const date = new Date(year, month, d); | const date = new Date(year, month, d); | ||||
| const isToday = this.isSameDay(date, this.today); | 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' | const cls = 'month-day' | ||||
| + (isToday ? ' month-day--today' : '') | + (isToday ? ' month-day--today' : '') | ||||
| + (isActive ? ' month-day--active' : ''); | + (isActive ? ' month-day--active' : ''); | ||||
| @@ -234,6 +364,8 @@ class WeekCalendar { | |||||
| this.monthEl.querySelectorAll('.month-day[data-date]').forEach(el => { | this.monthEl.querySelectorAll('.month-day[data-date]').forEach(el => { | ||||
| el.addEventListener('click', () => this.goToDate(new Date(el.dataset.date + 'T00:00:00'))); | el.addEventListener('click', () => this.goToDate(new Date(el.dataset.date + 'T00:00:00'))); | ||||
| }); | }); | ||||
| this.applyMonthDots(); | |||||
| } | } | ||||
| navigateMonth(direction) { | navigateMonth(direction) { | ||||
| @@ -244,6 +376,7 @@ class WeekCalendar { | |||||
| setTimeout(() => { | setTimeout(() => { | ||||
| this.monthDate.setMonth(this.monthDate.getMonth() + direction); | this.monthDate.setMonth(this.monthDate.getMonth() + direction); | ||||
| this.renderMonthGrid(); | this.renderMonthGrid(); | ||||
| this.fetchMonthDayCounts(this.monthDate.getFullYear(), this.monthDate.getMonth()); | |||||
| }, 160); | }, 160); | ||||
| } | } | ||||
| @@ -270,6 +403,10 @@ class WeekCalendar { | |||||
| return `${y}-${m}-${d}`; | return `${y}-${m}-${d}`; | ||||
| } | } | ||||
| formatMinutes(minutes) { | |||||
| return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`; | |||||
| } | |||||
| getWeekNumber(date) { | getWeekNumber(date) { | ||||
| const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); | const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); | ||||
| d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); | 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 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 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'); | const t = createTranslator('TT'); | ||||
| @@ -75,6 +76,9 @@ function buildEntryRowHTML(entry, animate = false) { | |||||
| </button> | </button> | ||||
| <button class="entry-row__btn entry-row__btn--delete" title="${t('btnDelete')}" data-action="delete"> | <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> | <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>`; | </button>`; | ||||
| const editFormHtml = invoiced ? '' : ` | const editFormHtml = invoiced ? '' : ` | ||||
| @@ -189,6 +193,8 @@ class EntryManager { | |||||
| }); | }); | ||||
| this.list.addEventListener('click', e => this.handleListClick(e)); | 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()); | document.getElementById('btn-create')?.addEventListener('click', () => this.createEntry()); | ||||
| this.checkAutoEdit(); | 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) { | async loadEntriesForDate(dateStr) { | ||||
| window.TT.activeDate = dateStr; | window.TT.activeDate = dateStr; | ||||
| @@ -27,7 +27,7 @@ | |||||
| .entry-list__footer { | .entry-list__footer { | ||||
| display: flex; | display: flex; | ||||
| justify-content: flex-end; | 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; | border-top: 1px solid $color-border; | ||||
| @include tablet { padding: $space-3 $space-4; } | @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); } | &--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; } | &--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; } | @media (hover: none) { opacity: 1; } | ||||
| @include tablet { opacity: 1; } | @include tablet { opacity: 1; } | ||||
| } | } | ||||
| // ─── Dragging-State ────────────────────────────────────────────────────── | |||||
| .entry-row--dragging { | |||||
| opacity: 0.4; | |||||
| } | |||||
| // ─── Lock-Indikator (invoiced) ──────────────────────────────────────────── | // ─── Lock-Indikator (invoiced) ──────────────────────────────────────────── | ||||
| .entry-row__lock-indicator { | .entry-row__lock-indicator { | ||||
| display: flex; | display: flex; | ||||
| align-items: center; | align-items: center; | ||||
| justify-content: 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; | flex-shrink: 0; | ||||
| color: $color-text-dark; | color: $color-text-dark; | ||||
| @@ -92,6 +92,7 @@ | |||||
| // ─── Einzelner Tag ─────────────────────────────────────────────────────────── | // ─── Einzelner Tag ─────────────────────────────────────────────────────────── | ||||
| .month-day { | .month-day { | ||||
| position: relative; | |||||
| display: flex; | display: flex; | ||||
| align-items: center; | align-items: center; | ||||
| justify-content: center; | justify-content: center; | ||||
| @@ -128,3 +129,20 @@ | |||||
| font-weight: $font-weight-bold; | 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; | 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 ─────────────────────────────────────────────────────────── | // ─── Kalender-Icon ─────────────────────────────────────────────────────────── | ||||
| .week-nav__cal { | .week-nav__cal { | ||||
| @include icon-btn(34px, $radius-md); | @include icon-btn(34px, $radius-md); | ||||
| @@ -225,6 +225,73 @@ class TimeTrackingController extends AbstractController | |||||
| return $this->json(['totalDuration' => $this->formatMinutes($totalMin)]); | 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 ──────────────────────────────────────────────────────────── | // ── Labels-API ──────────────────────────────────────────────────────────── | ||||
| #[Route('/api/labels', name: 'api_labels', methods: ['GET'])] | #[Route('/api/labels', name: 'api_labels', methods: ['GET'])] | ||||
| @@ -108,6 +108,7 @@ class TimeEntry | |||||
| { | { | ||||
| return [ | return [ | ||||
| 'id' => $this->id, | 'id' => $this->id, | ||||
| 'date' => $this->date->format('Y-m-d'), | |||||
| 'duration' => $this->duration, | 'duration' => $this->duration, | ||||
| 'durationFormatted' => $this->getDurationFormatted(), | 'durationFormatted' => $this->getDurationFormatted(), | ||||
| 'projectId' => $this->project?->getId(), | 'projectId' => $this->project?->getId(), | ||||
| @@ -46,6 +46,37 @@ class TimeEntryRepository extends ServiceEntityRepository | |||||
| return (int) $result; | 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 | public function findRunningTimerByUserId(int $userId): ?TimeEntry | ||||
| { | { | ||||
| return $this->createQueryBuilder('t') | return $this->createQueryBuilder('t') | ||||
| @@ -44,6 +44,12 @@ | |||||
| data-action="delete"> | data-action="delete"> | ||||
| {% include '_atoms/icon-delete.html.twig' %} | {% include '_atoms/icon-delete.html.twig' %} | ||||
| </button> | </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 %} | {% endif %} | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| @@ -66,6 +66,9 @@ window.TT = { | |||||
| errorDailyLimitExceeded: {{ 'app.entry.error_daily_limit_exceeded'|trans|json_encode|raw }}, | errorDailyLimitExceeded: {{ 'app.entry.error_daily_limit_exceeded'|trans|json_encode|raw }}, | ||||
| warnDurationLong: {{ 'app.entry.warn_duration_long'|trans|json_encode|raw }}, | warnDurationLong: {{ 'app.entry.warn_duration_long'|trans|json_encode|raw }}, | ||||
| invoicedTitle: {{ 'app.entry.invoiced_title'|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 }}, | noteShow: {{ 'app.entry.note_show'|trans|json_encode|raw }}, | ||||
| noteHide: {{ 'app.entry.note_hide'|trans|json_encode|raw }}, | noteHide: {{ 'app.entry.note_hide'|trans|json_encode|raw }}, | ||||
| btnTimerToggle: {{ 'app.stopwatch.resume'|trans|json_encode|raw }}, | btnTimerToggle: {{ 'app.stopwatch.resume'|trans|json_encode|raw }}, | ||||
| @@ -94,6 +94,9 @@ app: | |||||
| note_show: "+ Bemerkung hinzufügen" | note_show: "+ Bemerkung hinzufügen" | ||||
| note_hide: "× Bemerkung ausblenden" | note_hide: "× Bemerkung ausblenden" | ||||
| invoiced_title: "Abgerechnet – Bearbeiten nicht möglich" | 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_one: "Eintrag" | ||||
| count_other: "Einträge" | count_other: "Einträge" | ||||