Quellcode durchsuchen

drag and drop time entries and show dates with entries

master
FlorianEisenmenger vor 14 Stunden
Ursprung
Commit
2477919ed2
13 geänderte Dateien mit 463 neuen und 49 gelöschten Zeilen
  1. +2
    -1
      .claude/settings.local.json
  2. +49
    -0
      CLAUDE.md
  3. +187
    -46
      httpdocs/assets/scripts/calendar.js
  4. +63
    -0
      httpdocs/assets/scripts/entries.js
  5. +10
    -2
      httpdocs/assets/styles/components/_entry-list.scss
  6. +18
    -0
      httpdocs/assets/styles/components/_month-calendar.scss
  7. +23
    -0
      httpdocs/assets/styles/components/_week-nav.scss
  8. +67
    -0
      httpdocs/src/Controller/TimeTrackingController.php
  9. +1
    -0
      httpdocs/src/Entity/Tenant/TimeEntry.php
  10. +31
    -0
      httpdocs/src/Repository/Tenant/TimeEntryRepository.php
  11. +6
    -0
      httpdocs/templates/timetracking/_entry_row.html.twig
  12. +3
    -0
      httpdocs/templates/timetracking/week.html.twig
  13. +3
    -0
      httpdocs/translations/messages.de.yaml

+ 2
- 1
.claude/settings.local.json Datei anzeigen

@@ -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 *)"
]
}
}

+ 49
- 0
CLAUDE.md Datei anzeigen

@@ -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.


+ 187
- 46
httpdocs/assets/scripts/calendar.js Datei anzeigen

@@ -13,15 +13,21 @@ class WeekCalendar {
this.nextBtn = document.querySelector('.week-nav__arrow--next');
this.header = document.querySelector('.tt-header');

const raw = this.nav?.dataset.activeDate;
this.activeDate = raw ? new Date(raw + 'T00:00:00') : new Date();
this.today = new Date();
const raw = this.nav?.dataset.activeDate;
this.selectedDate = raw ? new Date(raw + 'T00:00:00') : new Date();
this.selectedDate.setHours(0, 0, 0, 0);

this.displayMonday = this.getMonday(this.selectedDate);

this.today = new Date();
this.today.setHours(0, 0, 0, 0);

this.monthOpen = false;
this.monthDate = new Date(this.activeDate);
this.monthDate = new Date(this.selectedDate);
this.monthEl = null;

this.dayCounts = {};

if (!this.nav) return;
this.init();
}
@@ -43,6 +49,8 @@ class WeekCalendar {
const dateStr = dayEl.dataset.date;
if (dateStr) this.goToDate(new Date(dateStr + 'T00:00:00'));
});

this.fetchDayCounts(this.displayMonday);
}

// ── Wochen-Navigation ─────────────────────────────────────────────────────
@@ -54,11 +62,12 @@ class WeekCalendar {
this.daysContainer.classList.add(slideOut);

setTimeout(() => {
const newDate = new Date(this.activeDate);
newDate.setDate(newDate.getDate() + direction * 7);
this.activeDate = newDate;
const newMonday = new Date(this.displayMonday);
newMonday.setDate(newMonday.getDate() + direction * 7);
this.displayMonday = newMonday;

this.renderWeekDays();
this.updateHeaderMeta();
this.updateWeekLabel();

this.daysContainer.classList.remove(slideOut);
this.daysContainer.classList.add(slideIn);
@@ -66,74 +75,193 @@ class WeekCalendar {
this.daysContainer.classList.remove(slideIn);
}));

window.history.pushState({}, '', `/week/${this.formatDate(this.getMonday(this.activeDate))}`);
window.entryManager?.loadEntriesForDate(this.formatDate(this.activeDate));
window.history.pushState({}, '', `/week/${this.formatDate(this.displayMonday)}`);
this.fetchDayCounts(this.displayMonday);
}, FADE_MS);
}

renderWeekDays() {
const monday = this.getMonday(this.activeDate);
this.daysContainer.innerHTML = '';

for (let i = 0; i < 7; i++) {
const d = new Date(monday);
const d = new Date(this.displayMonday);
d.setDate(d.getDate() + i);
const isActive = this.isSameDay(d, this.activeDate);
const isToday = this.isSameDay(d, this.today);
const isSelected = this.isSameDay(d, this.selectedDate);
const isToday = this.isSameDay(d, this.today);
const dateStr = this.formatDate(d);
const dayData = this.dayCounts[dateStr];
const hasEntries = dayData && dayData.count > 0;

const dayNum = String(d.getDate()).padStart(2, '0');
const monthShort = this.monthsShort[d.getMonth()] ?? '';

const a = document.createElement('a');
a.href = `/week/${this.formatDate(d)}`;
a.href = `/week/${dateStr}`;
a.className = 'week-nav__day'
+ (isActive ? ' week-nav__day--active' : '')
+ (isToday ? ' week-nav__day--today' : '');
a.dataset.date = this.formatDate(d);
+ (isSelected ? ' week-nav__day--active' : '')
+ (isToday ? ' week-nav__day--today' : '');
a.dataset.date = dateStr;
if (hasEntries) a.title = this.formatMinutes(dayData.minutes);
a.innerHTML = `
<span class="week-nav__day-name">${esc(this.weekdaysShort[i] ?? '')}</span>
<span class="week-nav__day-date">${dayNum}. ${esc(monthShort)}</span>
${hasEntries ? '<span class="week-nav__day-dot"></span>' : ''}
`;
this.daysContainer.appendChild(a);
}

this.initDropTargets();
}

goToDate(date) {
this.activeDate = date;
this.selectedDate = date;
this.displayMonday = this.getMonday(date);
this.renderWeekDays();
this.updateHeaderMeta();
window.history.pushState({}, '', `/week/${this.formatDate(date)}`);
window.entryManager?.loadEntriesForDate(this.formatDate(date));
if (this.monthOpen) this.closeMonth();
this.fetchDayCounts(this.displayMonday);
}

updateHeaderMeta() {
this.updateDateDisplay();
this.updateWeekLabel();
}

updateDateDisplay() {
const dateEl = document.querySelector('.tt-header__date');
const kwEl = document.querySelector('.tt-header__kw');

if (dateEl) {
const d = this.activeDate;
const day = d.getDate();
const month = this.months[d.getMonth()] ?? '';
const tomorrow = new Date(this.today); tomorrow.setDate(this.today.getDate() + 1);
const yesterday = new Date(this.today); yesterday.setDate(this.today.getDate() - 1);

const jsDay = d.getDay();
const isoIdx = jsDay === 0 ? 6 : jsDay - 1;
const weekday = this.weekdays[isoIdx] ?? '';

let prefix;
if (this.isSameDay(d, this.today)) prefix = t('today');
else if (this.isSameDay(d, tomorrow)) prefix = t('tomorrow');
else if (this.isSameDay(d, yesterday)) prefix = t('yesterday');
else prefix = weekday;

dateEl.textContent = `${prefix}, ${day}. ${month}`;
document.title = `${prefix}, ${day}. ${month}`;
}
if (!dateEl) return;

const d = this.selectedDate;
const day = d.getDate();
const month = this.months[d.getMonth()] ?? '';
const tomorrow = new Date(this.today); tomorrow.setDate(this.today.getDate() + 1);
const yesterday = new Date(this.today); yesterday.setDate(this.today.getDate() - 1);

const jsDay = d.getDay();
const isoIdx = jsDay === 0 ? 6 : jsDay - 1;
const weekday = this.weekdays[isoIdx] ?? '';

let prefix;
if (this.isSameDay(d, this.today)) prefix = t('today');
else if (this.isSameDay(d, tomorrow)) prefix = t('tomorrow');
else if (this.isSameDay(d, yesterday)) prefix = t('yesterday');
else prefix = weekday;

dateEl.textContent = `${prefix}, ${day}. ${month}`;
document.title = `${prefix}, ${day}. ${month}`;
}

updateWeekLabel() {
const kwEl = document.querySelector('.tt-header__kw');
const weekNum = this.getWeekNumber(this.displayMonday);
if (kwEl) kwEl.textContent = `${t('weekLabel')} ${weekNum}`;
window._updateWeekToggle?.(weekNum);
}

// ── Dot-Indikatoren ───────────────────────────────────────────────────────

async fetchDayCounts(monday) {
const sunday = new Date(monday);
sunday.setDate(sunday.getDate() + 6);

try {
const res = await fetch(`/api/entries/day-counts?from=${this.formatDate(monday)}&to=${this.formatDate(sunday)}`);
if (!res.ok) return;
const counts = await res.json();
Object.assign(this.dayCounts, counts);
this.applyDots();
} catch { /* silent */ }
}

async fetchMonthDayCounts(year, month) {
const from = new Date(year, month, 1);
const to = new Date(year, month + 1, 0);

try {
const res = await fetch(`/api/entries/day-counts?from=${this.formatDate(from)}&to=${this.formatDate(to)}`);
if (!res.ok) return;
const counts = await res.json();
Object.assign(this.dayCounts, counts);
this.applyMonthDots();
} catch { /* silent */ }
}

if (kwEl) kwEl.textContent = `${t('weekLabel')} ${this.getWeekNumber(this.activeDate)}`;
window._updateWeekToggle?.(this.getWeekNumber(this.activeDate));
applyDots() {
this.daysContainer?.querySelectorAll('.week-nav__day').forEach(dayEl => {
const dateStr = dayEl.dataset.date;
const dayData = this.dayCounts[dateStr];
const hasEntries = dayData && dayData.count > 0;
const existing = dayEl.querySelector('.week-nav__day-dot');

if (hasEntries) {
dayEl.title = this.formatMinutes(dayData.minutes);
if (!existing) {
const dot = document.createElement('span');
dot.className = 'week-nav__day-dot';
dayEl.appendChild(dot);
}
} else {
dayEl.removeAttribute('title');
if (existing) existing.remove();
}
});
}

applyMonthDots() {
if (!this.monthEl) return;
this.monthEl.querySelectorAll('.month-day[data-date]').forEach(el => {
const dateStr = el.dataset.date;
const dayData = this.dayCounts[dateStr];
const hasEntries = dayData && dayData.count > 0;
const existing = el.querySelector('.month-day__dot');

if (hasEntries) {
el.title = this.formatMinutes(dayData.minutes);
if (!existing) {
const dot = document.createElement('span');
dot.className = 'month-day__dot';
el.appendChild(dot);
}
} else {
el.removeAttribute('title');
if (existing) existing.remove();
}
});
}

// ── Drop-Targets auf Wochentagen ──────────────────────────────────────────

initDropTargets() {
this.daysContainer?.querySelectorAll('.week-nav__day').forEach(dayEl => {
dayEl.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
dayEl.classList.add('week-nav__day--drop-target');
});

dayEl.addEventListener('dragleave', () => {
dayEl.classList.remove('week-nav__day--drop-target');
});

dayEl.addEventListener('drop', e => {
e.preventDefault();
dayEl.classList.remove('week-nav__day--drop-target');
const entryId = e.dataTransfer.getData('text/entry-id');
const targetDate = dayEl.dataset.date;
if (entryId && targetDate) {
window.entryManager?.moveEntry(parseInt(entryId, 10), targetDate);
}
});
});
}

refreshAfterMove() {
this.fetchDayCounts(this.displayMonday);
if (this.monthOpen) {
this.fetchMonthDayCounts(this.monthDate.getFullYear(), this.monthDate.getMonth());
}
}

// ── Monats-Ansicht ────────────────────────────────────────────────────────
@@ -142,7 +270,7 @@ class WeekCalendar {

openMonth() {
this.monthOpen = true;
this.monthDate = new Date(this.activeDate);
this.monthDate = new Date(this.selectedDate);
this.monthEl = document.createElement('div');
this.monthEl.className = 'month-calendar month-calendar--hidden';

@@ -157,6 +285,8 @@ class WeekCalendar {
this.monthEl.classList.add('month-calendar--visible');
}));
this.calBtn.classList.add('week-nav__cal--active');

this.fetchMonthDayCounts(this.monthDate.getFullYear(), this.monthDate.getMonth());
}

closeMonth() {
@@ -214,7 +344,7 @@ class WeekCalendar {
for (let d = 1; d <= daysInMonth; d++) {
const date = new Date(year, month, d);
const isToday = this.isSameDay(date, this.today);
const isActive= this.isSameDay(date, this.activeDate);
const isActive= this.isSameDay(date, this.selectedDate);
const cls = 'month-day'
+ (isToday ? ' month-day--today' : '')
+ (isActive ? ' month-day--active' : '');
@@ -234,6 +364,8 @@ class WeekCalendar {
this.monthEl.querySelectorAll('.month-day[data-date]').forEach(el => {
el.addEventListener('click', () => this.goToDate(new Date(el.dataset.date + 'T00:00:00')));
});

this.applyMonthDots();
}

navigateMonth(direction) {
@@ -244,6 +376,7 @@ class WeekCalendar {
setTimeout(() => {
this.monthDate.setMonth(this.monthDate.getMonth() + direction);
this.renderMonthGrid();
this.fetchMonthDayCounts(this.monthDate.getFullYear(), this.monthDate.getMonth());
}, 160);
}

@@ -270,6 +403,10 @@ class WeekCalendar {
return `${y}-${m}-${d}`;
}

formatMinutes(minutes) {
return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`;
}

getWeekNumber(date) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
@@ -278,4 +415,8 @@ class WeekCalendar {
}
}

document.addEventListener('DOMContentLoaded', () => { new WeekCalendar(); });
let weekCalendar = null;
document.addEventListener('DOMContentLoaded', () => {
weekCalendar = new WeekCalendar();
window.weekCalendar = weekCalendar;
});

+ 63
- 0
httpdocs/assets/scripts/entries.js Datei anzeigen

@@ -9,6 +9,7 @@ const LAST_SERVICE_KEY = 'tt_last_service_id';
const NOTE_KEY = 'tt_minimal_note_open';

const LOCK_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="3" y="7.5" width="10" height="7" rx="1.5" stroke="currentColor" stroke-width="1.3"/><path d="M5.5 7.5V5.5a2.5 2.5 0 015 0v2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`;
const MOVE_SVG = `<svg viewBox="0 0 16 16" fill="none"><circle cx="6" cy="3.5" r="1.2" fill="currentColor"/><circle cx="10" cy="3.5" r="1.2" fill="currentColor"/><circle cx="6" cy="8" r="1.2" fill="currentColor"/><circle cx="10" cy="8" r="1.2" fill="currentColor"/><circle cx="6" cy="12.5" r="1.2" fill="currentColor"/><circle cx="10" cy="12.5" r="1.2" fill="currentColor"/></svg>`;

const t = createTranslator('TT');

@@ -75,6 +76,9 @@ function buildEntryRowHTML(entry, animate = false) {
</button>
<button class="entry-row__btn entry-row__btn--delete" title="${t('btnDelete')}" data-action="delete">
<svg viewBox="0 0 16 16" fill="none"><path d="M3 4h10M6 4V2h4v2M5 4l.5 9h5l.5-9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="entry-row__btn entry-row__btn--move" title="${t('btnMove')}" data-action="move" draggable="true">
${MOVE_SVG}
</button>`;

const editFormHtml = invoiced ? '' : `
@@ -189,6 +193,8 @@ class EntryManager {
});

this.list.addEventListener('click', e => this.handleListClick(e));
this.list.addEventListener('dragstart', e => this.handleDragStart(e));
this.list.addEventListener('dragend', e => this.handleDragEnd(e));
document.getElementById('btn-create')?.addEventListener('click', () => this.createEntry());

this.checkAutoEdit();
@@ -433,6 +439,63 @@ class EntryManager {
}
}

handleDragStart(e) {
const moveBtn = e.target.closest('[data-action="move"]');
if (!moveBtn) { e.preventDefault(); return; }

const row = moveBtn.closest('.entry-row');
if (!row || row.dataset.invoiced === 'true') { e.preventDefault(); return; }

e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/entry-id', row.dataset.id);

const display = row.querySelector('.entry-row__display');
if (display) e.dataTransfer.setDragImage(display, 20, 20);

row.classList.add('entry-row--dragging');
}

handleDragEnd(e) {
const row = e.target.closest('.entry-row');
if (row) row.classList.remove('entry-row--dragging');
document.querySelectorAll('.week-nav__day--drop-target').forEach(el => {
el.classList.remove('week-nav__day--drop-target');
});
}

async moveEntry(entryId, targetDate) {
const row = document.getElementById(`entry-${entryId}`);
if (!row) return;

try {
const res = await fetch(`/api/entries/${entryId}/move`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date: targetDate }),
});

if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(t('errorMove') + (err.error ? `\n${err.error}` : ''));
return;
}

const data = await res.json();

if (data.entry.date !== window.TT.activeDate) {
removeWithAnimation(row, 'entry-row--removing');
setTimeout(() => {
this.updateTotal(data.oldDateTotal);
this.checkIfEmpty();
}, ANIMATION_MS);
}

window.weekCalendar?.refreshAfterMove();
} catch {
alert(t('errorMove'));
}
}

async loadEntriesForDate(dateStr) {
window.TT.activeDate = dateStr;



+ 10
- 2
httpdocs/assets/styles/components/_entry-list.scss Datei anzeigen

@@ -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;



+ 18
- 0
httpdocs/assets/styles/components/_month-calendar.scss Datei anzeigen

@@ -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;
}
}

+ 23
- 0
httpdocs/assets/styles/components/_week-nav.scss Datei anzeigen

@@ -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);


+ 67
- 0
httpdocs/src/Controller/TimeTrackingController.php Datei anzeigen

@@ -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'])]


+ 1
- 0
httpdocs/src/Entity/Tenant/TimeEntry.php Datei anzeigen

@@ -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(),


+ 31
- 0
httpdocs/src/Repository/Tenant/TimeEntryRepository.php Datei anzeigen

@@ -46,6 +46,37 @@ class TimeEntryRepository extends ServiceEntityRepository
return (int) $result;
}

/**
* @return array<string, array{count: int, minutes: int}>
*/
public function countEntriesByDays(\DateTimeImmutable $from, \DateTimeImmutable $to, int $userId): array
{
$rows = $this->createQueryBuilder('t')
->select('t.date AS day, COUNT(t.id) AS cnt, SUM(t.duration) AS totalMinutes')
->where('t.date >= :from')
->andWhere('t.date <= :to')
->andWhere('t.userId = :userId')
->setParameter('from', $from->format('Y-m-d'))
->setParameter('to', $to->format('Y-m-d'))
->setParameter('userId', $userId)
->groupBy('t.date')
->getQuery()
->getResult();

$counts = [];
foreach ($rows as $row) {
$dateStr = $row['day'] instanceof \DateTimeInterface
? $row['day']->format('Y-m-d')
: $row['day'];
$counts[$dateStr] = [
'count' => (int) $row['cnt'],
'minutes' => (int) $row['totalMinutes'],
];
}

return $counts;
}

public function findRunningTimerByUserId(int $userId): ?TimeEntry
{
return $this->createQueryBuilder('t')


+ 6
- 0
httpdocs/templates/timetracking/_entry_row.html.twig Datei anzeigen

@@ -44,6 +44,12 @@
data-action="delete">
{% include '_atoms/icon-delete.html.twig' %}
</button>
<button class="entry-row__btn entry-row__btn--move"
title="{{ 'app.entry.btn_move'|trans }}"
data-action="move"
draggable="true">
<svg viewBox="0 0 16 16" fill="none"><circle cx="6" cy="3.5" r="1.2" fill="currentColor"/><circle cx="10" cy="3.5" r="1.2" fill="currentColor"/><circle cx="6" cy="8" r="1.2" fill="currentColor"/><circle cx="10" cy="8" r="1.2" fill="currentColor"/><circle cx="6" cy="12.5" r="1.2" fill="currentColor"/><circle cx="10" cy="12.5" r="1.2" fill="currentColor"/></svg>
</button>
{% endif %}
</div>
</div>


+ 3
- 0
httpdocs/templates/timetracking/week.html.twig Datei anzeigen

@@ -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 }},


+ 3
- 0
httpdocs/translations/messages.de.yaml Datei anzeigen

@@ -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"



Laden…
Abbrechen
Speichern