diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 987d56d..a7b0a20 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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 *)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index eaf12cd..ce72ff8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/httpdocs/assets/scripts/calendar.js b/httpdocs/assets/scripts/calendar.js index f5089de..55820bc 100644 --- a/httpdocs/assets/scripts/calendar.js +++ b/httpdocs/assets/scripts/calendar.js @@ -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 = ` ${esc(this.weekdaysShort[i] ?? '')} ${dayNum}. ${esc(monthShort)} + ${hasEntries ? '' : ''} `; 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; +}); diff --git a/httpdocs/assets/scripts/entries.js b/httpdocs/assets/scripts/entries.js index a9abcb3..847958c 100644 --- a/httpdocs/assets/scripts/entries.js +++ b/httpdocs/assets/scripts/entries.js @@ -9,6 +9,7 @@ const LAST_SERVICE_KEY = 'tt_last_service_id'; const NOTE_KEY = 'tt_minimal_note_open'; const LOCK_SVG = ``; +const MOVE_SVG = ``; const t = createTranslator('TT'); @@ -75,6 +76,9 @@ function buildEntryRowHTML(entry, animate = false) { + `; 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; diff --git a/httpdocs/assets/styles/components/_entry-list.scss b/httpdocs/assets/styles/components/_entry-list.scss index e94568b..bb20d10 100644 --- a/httpdocs/assets/styles/components/_entry-list.scss +++ b/httpdocs/assets/styles/components/_entry-list.scss @@ -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; diff --git a/httpdocs/assets/styles/components/_month-calendar.scss b/httpdocs/assets/styles/components/_month-calendar.scss index 5004232..2c13ae1 100644 --- a/httpdocs/assets/styles/components/_month-calendar.scss +++ b/httpdocs/assets/styles/components/_month-calendar.scss @@ -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; + } +} diff --git a/httpdocs/assets/styles/components/_week-nav.scss b/httpdocs/assets/styles/components/_week-nav.scss index 9295700..74fc405 100644 --- a/httpdocs/assets/styles/components/_week-nav.scss +++ b/httpdocs/assets/styles/components/_week-nav.scss @@ -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); diff --git a/httpdocs/src/Controller/TimeTrackingController.php b/httpdocs/src/Controller/TimeTrackingController.php index 8755c23..a26896b 100644 --- a/httpdocs/src/Controller/TimeTrackingController.php +++ b/httpdocs/src/Controller/TimeTrackingController.php @@ -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'])] diff --git a/httpdocs/src/Entity/Tenant/TimeEntry.php b/httpdocs/src/Entity/Tenant/TimeEntry.php index a031344..ed2e072 100644 --- a/httpdocs/src/Entity/Tenant/TimeEntry.php +++ b/httpdocs/src/Entity/Tenant/TimeEntry.php @@ -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(), diff --git a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php index 96d757b..86385fb 100644 --- a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php +++ b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php @@ -46,6 +46,37 @@ class TimeEntryRepository extends ServiceEntityRepository return (int) $result; } + /** + * @return array + */ + 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') diff --git a/httpdocs/templates/timetracking/_entry_row.html.twig b/httpdocs/templates/timetracking/_entry_row.html.twig index 1ce12fd..e0f405d 100644 --- a/httpdocs/templates/timetracking/_entry_row.html.twig +++ b/httpdocs/templates/timetracking/_entry_row.html.twig @@ -44,6 +44,12 @@ data-action="delete"> {% include '_atoms/icon-delete.html.twig' %} + {% endif %} diff --git a/httpdocs/templates/timetracking/week.html.twig b/httpdocs/templates/timetracking/week.html.twig index 24872f6..d22851f 100644 --- a/httpdocs/templates/timetracking/week.html.twig +++ b/httpdocs/templates/timetracking/week.html.twig @@ -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 }}, diff --git a/httpdocs/translations/messages.de.yaml b/httpdocs/translations/messages.de.yaml index b9804c2..4207f42 100644 --- a/httpdocs/translations/messages.de.yaml +++ b/httpdocs/translations/messages.de.yaml @@ -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"