Преглед на файлове

drag and drop time entries and show dates with entries

master
FlorianEisenmenger преди 14 часа
родител
ревизия
2477919ed2
променени са 13 файла, в които са добавени 463 реда и са изтрити 49 реда
  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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

@@ -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 Целия файл

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



Зареждане…
Отказ
Запис