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"