aziros/src/app/Livewire/Calendar/Index.php

895 lines
27 KiB
PHP

<?php
namespace App\Livewire\Calendar;
use App\Models\Event;
use Livewire\Component;
use Carbon\Carbon;
class Index extends Component
{
public $view = 'month';
public $date;
public $events = [];
public $search = '';
public $from = null;
public $to = null;
public $rawEvents = [];
public $holidays = [
'2026-04-06' => 'Ostermontag',
];
protected $queryString = [
'view' => ['except' => 'month'],
'date' => ['except' => ''],
'search' => ['except' => ''],
'from' => ['except' => ''],
'to' => ['except' => ''],
];
protected $listeners = ['eventCreated' => 'loadEvents'];
public function mount()
{
$tz = auth()->user()->timezone;
$this->date = $this->date
? Carbon::parse($this->date, $tz)
: now($tz);
$this->loadEvents();
}
public function updatedView()
{
$this->loadEvents();
}
public function setView($view)
{
$this->view = $view;
$this->loadEvents();
}
public function loadEvents()
{
$tz = auth()->user()->timezone;
$user = auth()->user();
// 🔥 VIEW RANGE
if ($this->view === 'month') {
$start = $this->date->copy()->startOfMonth()->startOfWeek();
$end = $this->date->copy()->endOfMonth()->endOfWeek();
} elseif ($this->view === 'week') {
$start = $this->date->copy()->startOfWeek();
$end = $this->date->copy()->endOfWeek();
} else {
$start = $this->date->copy()->startOfDay();
$end = $this->date->copy()->endOfDay();
}
$query = $user->events();
// 🔥 SEARCH
if ($this->search) {
$query->where('title', 'like', '%' . $this->search . '%');
}
// 🔥 FILTER (richtig für Multi-Day!)
if ($this->from) {
$from = Carbon::parse($this->from)->startOfDay();
$query->where(function ($q) use ($from) {
$q->where('starts_at', '>=', $from)
->orWhere('ends_at', '>=', $from);
});
}
if ($this->to) {
$to = Carbon::parse($this->to)->endOfDay();
$query->where(function ($q) use ($to) {
$q->where('starts_at', '<=', $to)
->orWhere('ends_at', '<=', $to);
});
}
// 🔥 RANGE (WICHTIG)
$rawEvents = $query->where(function ($q) use ($start, $end) {
$q->whereBetween('starts_at', [$start, $end])
->orWhereBetween('ends_at', [$start, $end])
->orWhere(function ($q2) use ($start, $end) {
$q2->where('starts_at', '<=', $start)
->where('ends_at', '>=', $end);
});
})->get();
$this->rawEvents = $rawEvents;
// 🔥 SPLIT EVENTS IN DAYS
$events = [];
foreach ($rawEvents as $event) {
$startDate = $event->starts_at
->copy()
->setTimezone($tz)
->startOfDay();
$endDate = $event->ends_at
? $event->ends_at->copy()->setTimezone($tz)->startOfDay()
: $startDate;
for ($date = $startDate->copy(); $date <= $endDate; $date->addDay()) {
$events[$date->format('Y-m-d')][] = $event;
}
}
$this->events = collect($events)->map(fn($g) => collect($g));
}
public function updatedSearch()
{
$this->loadEvents();
}
public function updatedFrom()
{
$this->loadEvents();
}
public function updatedTo()
{
$this->loadEvents();
}
public function updateEventTime($eventId, $start, $end)
{
$event = \App\Models\Event::findOrFail($eventId);
$tz = auth()->user()->timezone ?? 'UTC';
$event->update([
'starts_at' => Carbon::parse($start, $tz)->utc(),
'ends_at' => $end ? Carbon::parse($end, $tz)->utc() : null,
]);
$this->loadEvents();
}
public function moveEventToDate($eventId, $date)
{
$event = \App\Models\Event::findOrFail($eventId);
$tz = auth()->user()->timezone ?? 'UTC';
$start = $event->starts_at->copy()->timezone($tz);
$end = $event->ends_at?->copy()->timezone($tz);
/*
|--------------------------------------------------------------------------
| 🔥 FALL 1: ALL DAY ODER MULTI DAY
|--------------------------------------------------------------------------
*/
$isMultiDay = $end && $start->toDateString() !== $end->toDateString();
if ($event->is_all_day || $isMultiDay) {
// 👉 Tage-Differenz behalten (NICHT Sekunden!)
$daySpan = $end
? $start->startOfDay()->diffInDays($end->startOfDay())
: 0;
// 👉 neuer Start (immer 00:00 bei allday)
$newStart = Carbon::parse($date, $tz)->startOfDay();
// 👉 neuer End
$newEnd = $end
? $newStart->copy()->addDays($daySpan)->endOfDay()
: null;
} else {
/*
|--------------------------------------------------------------------------
| 🔥 FALL 2: NORMALER TERMIN
|--------------------------------------------------------------------------
*/
$duration = $end
? $start->diffInSeconds($end)
: 3600;
$newStart = Carbon::parse(
$date . ' ' . $start->format('H:i'),
$tz
);
$newEnd = $end
? $newStart->copy()->addSeconds($duration)
: null;
}
/*
|--------------------------------------------------------------------------
| SAVE (UTC!)
|--------------------------------------------------------------------------
*/
$event->update([
'starts_at' => $newStart->utc(),
'ends_at' => $newEnd?->utc(),
]);
$this->loadEvents();
}
// public function moveEventToDate($eventId, $date)
// {
// $event = \App\Models\Event::findOrFail($eventId);
//
// $tz = auth()->user()->timezone ?? 'UTC';
//
// // aktuelle Zeiten holen (in User TZ)
// $start = $event->starts_at->copy()->timezone($tz);
// $end = $event->ends_at?->copy()->timezone($tz);
//
// // 🔥 Dauer berechnen (wichtig für Multi-Day)
// $duration = $end
// ? $start->diffInSeconds($end)
// : 3600;
//
// // 🔥 neues Datum + gleiche Uhrzeit
// $newStart = \Carbon\Carbon::parse(
// $date . ' ' . $start->format('H:i'),
// $tz
// )->utc();
//
// $newEnd = $end
// ? $newStart->copy()->addSeconds($duration)
// : null;
//
// $event->update([
// 'starts_at' => $newStart,
// 'ends_at' => $newEnd,
// ]);
//
// $this->loadEvents();
// }
public function goToToday()
{
$this->date = now(auth()->user()->timezone);
$this->loadEvents();
}
public function next()
{
if ($this->view === 'month') $this->date->addMonth();
if ($this->view === 'week') $this->date->addWeek();
if ($this->view === 'day') $this->date->addDay();
$this->loadEvents();
}
public function prev()
{
if ($this->view === 'month') $this->date->subMonth();
if ($this->view === 'week') $this->date->subWeek();
if ($this->view === 'day') $this->date->subDay();
$this->loadEvents();
}
public function moveEventToDateTime($eventId, $date, $time)
{
$event = Event::findOrFail($eventId);
$tz = auth()->user()->timezone ?? config('app.timezone');
if (!preg_match('/^\d{2}:\d{2}$/', $time)) {
return;
}
[$hour, $minute] = explode(':', $time);
$hour = min(max((int)$hour, 0), 23);
$minute = min(max((int)$minute, 0), 59);
// 🔥 WICHTIG: alte Zeiten in USER TZ holen
$oldStart = $event->starts_at->copy()->setTimezone($tz);
$oldEnd = $event->ends_at?->copy()->setTimezone($tz);
$duration = $oldEnd
? $oldStart->diffInMinutes($oldEnd)
: 60;
if ($duration <= 0) {
$duration = 60;
}
// 🔥 neue Zeit in USER TZ setzen
$newStart = Carbon::parse($date, $tz)->setTime($hour, $minute);
$newEnd = $newStart->copy()->addMinutes($duration);
// 🔥 zurück in UTC speichern
$event->starts_at = $newStart->copy()->utc();
$event->ends_at = $newEnd->copy()->utc();
$event->save();
$this->loadEvents();
}
public function updateEventTimeAndDate($eventId, $date, $start, $end)
{
$event = Event::findOrFail($eventId);
$tz = auth()->user()->timezone ?? config('app.timezone');
$start = Carbon::parse($date.' '.$start, $tz)->utc();
$end = Carbon::parse($date.' '.$end, $tz)->utc();
if ($end->lessThanOrEqualTo($start)) {
$end = $start->copy()->addHour();
}
$event->update([
'starts_at' => $start,
'ends_at' => $end,
]);
$this->loadEvents();
}
public function updateEventRange($id, $startDate, $endDate, $startTime, $endTime)
{
$event = Event::findOrFail($id);
$tz = auth()->user()->timezone;
$start = Carbon::parse("$startDate $startTime", $tz)->utc();
$end = Carbon::parse("$endDate $endTime", $tz)->utc();
if ($end->lessThanOrEqualTo($start)) {
$end = $start->copy()->addHour();
}
$event->update([
'starts_at' => $start,
'ends_at' => $end,
]);
$this->loadEvents();
}
private function transformAllDayEvents($events)
{
return $events->map(function ($event) {
$start = $event->starts_at->copy();
$end = $event->ends_at?->copy() ?? $start;
$startDay = $start->startOfDay();
$endDay = $end->startOfDay();
return [
'id' => $event->id,
'title' => $event->title,
'color' => $event->color,
'start_day' => $startDay,
'end_day' => $endDay,
'duration_days' => $startDay->diffInDays($endDay) + 1,
];
});
}
private function mapAllDayToWeek($events, $weekStart)
{
return $events->map(function ($event) use ($weekStart) {
$startOffset = $event['start_day']->diffInDays($weekStart, false);
$endOffset = $event['end_day']->diffInDays($weekStart, false);
// clamp auf Woche
$start = max($startOffset, 0);
$end = min($endOffset, 6);
$span = ($end - $start) + 1;
return [
...$event,
'left' => ($start / 7) * 100,
'width' => ($span / 7) * 100,
];
});
}
public function getCalendarDaysProperty(): \Illuminate\Support\Collection
{
$tz = auth()->user()->timezone;
return collect(range(0, 6))->map(function ($i) use ($tz) {
$day = $this->date->copy()->startOfWeek()->addDays($i);
$key = $day->format('Y-m-d');
$events = collect($this->events[$key] ?? []);
/*
|--------------------------------------------------------------------------
| 🔥 ALLDAY + MULTIDAY RAUSFILTERN (WICHTIG!)
|--------------------------------------------------------------------------
*/
$filtered = $events->reject(function ($event) use ($tz) {
$start = $event->starts_at->copy()->setTimezone($tz);
$end = $event->ends_at?->copy()->setTimezone($tz) ?? $start;
// 👉 gleiche Logik wie in getAllDayEventsProperty
$isMultiDay = $end->startOfDay()->gt($start->startOfDay());
return $event->is_all_day || $isMultiDay;
});
/*
|--------------------------------------------------------------------------
| 🔥 TIMED EVENTS TRANSFORMIEREN
|--------------------------------------------------------------------------
*/
$mapped = $filtered
->map(fn($event) => $this->transformEvent($event, $day, $tz))
->sortBy('start')
->values();
/*
|--------------------------------------------------------------------------
| 🔥 OVERLAP FIX
|--------------------------------------------------------------------------
*/
$timed = $mapped->map(function ($event) use ($mapped) {
$overlapping = $mapped->filter(function ($e) use ($event) {
return $e['start'] < $event['end']
&& $e['end'] > $event['start'];
})->values();
$count = max($overlapping->count(), 1);
$index = $overlapping->pluck('id')->search($event['id']);
$index = $index === false ? 0 : $index;
$event['count'] = $count;
$event['index'] = $index;
return $event;
});
return [
'date' => $day,
'key' => $key,
'timed' => $timed,
];
});
}
// public function getCalendarDaysProperty(): \Illuminate\Support\Collection
// {
// $tz = auth()->user()->timezone;
//
// return collect(range(0, 6))->map(function ($i) use ($tz) {
//
// $day = $this->date->copy()->startOfWeek()->addDays($i);
// $key = $day->format('Y-m-d');
//
// $events = collect($this->events[$key] ?? []);
//
// /*
// |--------------------------------------------------------------------------
// | 🔥 NUR ALLDAY RAUS
// |--------------------------------------------------------------------------
// */
// $mapped = $events
// ->reject(fn($e) => $e->is_all_day)
// ->map(fn($event) => $this->transformEvent($event, $day, $tz))
// ->sortBy('start')
// ->values();
//
// /*
// |--------------------------------------------------------------------------
// | OVERLAP
// |--------------------------------------------------------------------------
// */
// $timed = $mapped->map(function ($event) use ($mapped) {
//
// $overlapping = $mapped->filter(function ($e) use ($event) {
// return $e['start'] < $event['end']
// && $e['end'] > $event['start'];
// })->values();
//
// $count = max($overlapping->count(), 1);
//
// $index = $overlapping->pluck('id')->search($event['id']);
// $index = $index === false ? 0 : $index;
//
// $event['count'] = $count;
// $event['index'] = $index;
//
// return $event;
// });
//
// return [
// 'date' => $day,
// 'key' => $key,
// 'timed' => $timed,
// ];
// });
// }
// public function getCalendarDaysProperty(): \Illuminate\Support\Collection
// {
// $tz = auth()->user()->timezone;
//
// return collect(range(0, 6))->map(function ($i) use ($tz) {
//
// $day = $this->date->copy()->startOfWeek()->addDays($i);
// $key = $day->format('Y-m-d');
//
// $events = collect($this->events[$key] ?? []);
//
// // 👉 AllDay Events
// $allDay = $events->filter(fn($e) => $e->is_all_day);
//
// // 👉 Timed Events transformieren
// $mapped = $events
// ->reject(fn($e) => $e->is_all_day)
// ->map(fn($event) => $this->transformEvent($event, $day, $tz))
// ->sortBy('start') // 🔥 wichtig für stabile Reihenfolge
// ->values();
//
// // 👉 Overlap berechnen
// $timed = $mapped->map(function ($event) use ($mapped) {
//
// $overlapping = $mapped->filter(function ($e) use ($event) {
// return $e['start'] < $event['end']
// && $e['end'] > $event['start'];
// })->values();
//
// $count = max($overlapping->count(), 1);
//
// $index = $overlapping->pluck('id')->search($event['id']);
// $index = $index === false ? 0 : $index;
//
// $event['count'] = $count;
// $event['index'] = $index;
//
// return $event;
// });
//
// return [
// 'date' => $day,
// 'key' => $key,
// 'allDay' => $allDay,
// 'timed' => $timed,
// ];
// });
// }
// public function getAllDayEventsProperty()
// {
// $tz = auth()->user()->timezone;
//
// $weekStart = $this->date->copy()->startOfWeek();
// $weekEnd = $this->date->copy()->endOfWeek();
//
// return collect($this->rawEvents)
// ->filter(fn($event) => $event->is_all_day == true)
// ->map(function ($event) use ($weekStart, $weekEnd, $tz) {
//
// $start = $event->starts_at->copy()->setTimezone($tz);
// $end = $event->ends_at
// ? $event->ends_at->copy()->setTimezone($tz)
// : $start;
//
// if ($end->lt($weekStart) || $start->gt($weekEnd)) {
// return null;
// }
//
// $startClamped = $start->copy()->max($weekStart)->startOfDay();
// $endClamped = $end->copy()->min($weekEnd)->startOfDay();
//
// $startOffset = $weekStart->diffInDays($startClamped);
// $endOffset = $weekStart->diffInDays($endClamped);
//
// $span = max(($endOffset - $startOffset) + 1, 1);
//
// return [
// 'id' => $event->id,
// 'title' => $event->title,
// 'color' => $event->color,
//
// // 🔥 DAS HAT GEFEHLT
// 'start' => $startClamped,
// 'end' => $endClamped,
//
// 'left' => ($startOffset / 7) * 100,
// 'width' => ($span / 7) * 100,
// ];
// })
// ->filter()
// ->values();
// }
// public function getAllDayEventsProperty()
// {
// $tz = auth()->user()->timezone;
//
// $weekStart = $this->date->copy()->startOfWeek()->startOfDay();
// $weekEnd = $this->date->copy()->endOfWeek()->endOfDay();
//
// $events = collect($this->rawEvents)
//
// ->filter(function ($event) use ($tz) {
//
// $start = $event->starts_at->copy()->setTimezone($tz);
// $end = $event->ends_at?->copy()->setTimezone($tz) ?? $start;
//
// // 👉 echtes AllDay oder MultiDay
// return $event->is_all_day
// || $end->startOfDay()->gt($start->startOfDay());
// })
//
// ->map(function ($event) use ($tz, $weekStart, $weekEnd) {
//
// $start = $event->starts_at->copy()->setTimezone($tz);
// $end = $event->ends_at
// ? $event->ends_at->copy()->setTimezone($tz)
// : $start;
//
// /*
// |--------------------------------------------------------------------------
// | ❌ außerhalb → raus
// |--------------------------------------------------------------------------
// */
// if ($end->lt($weekStart) || $start->gt($weekEnd)) {
// return null;
// }
//
// /*
// |--------------------------------------------------------------------------
// | 🔥 CLAMP AUF WOCHE
// |--------------------------------------------------------------------------
// */
// $startClamped = $start->lt($weekStart)
// ? $weekStart->copy()
// : $start->copy();
//
// $endClamped = $end->gt($weekEnd)
// ? $weekEnd->copy()
// : $end->copy();
//
// /*
// |--------------------------------------------------------------------------
// | 🔥 WICHTIG: START/END auf DAY LEVEL
// |--------------------------------------------------------------------------
// */
// $startDay = $startClamped->copy()->startOfDay();
// $endDay = $endClamped->copy()->startOfDay();
//
// /*
// |--------------------------------------------------------------------------
// | 🔥 OFFSET BERECHNUNG (STABIL)
// |--------------------------------------------------------------------------
// */
// $startOffset = $weekStart->diffInDays($startDay);
// $endOffset = $weekStart->diffInDays($endDay);
//
// $span = max(1, ($endOffset - $startOffset) + 1);
//
// return [
// 'id' => $event->id,
// 'title' => $event->title,
// 'color' => $event->color,
//
// 'start' => $startDay,
// 'end' => $endDay,
//
// 'left' => ($startOffset / 7) * 100,
// 'width' => ($span / 7) * 100,
// ];
// })
//
// ->filter()
// ->sortBy('start')
// ->values();
//
// return $this->stackAllDayEvents($events);
// }
public function getAllDayEventsProperty()
{
$tz = auth()->user()->timezone;
$weekStart = $this->date->copy()->startOfWeek()->startOfDay();
$weekEnd = $this->date->copy()->endOfWeek()->endOfDay();
$events = collect($this->rawEvents)
->filter(function ($event) use ($tz) {
$start = $event->starts_at->copy()->setTimezone($tz);
$end = $event->ends_at?->copy()->setTimezone($tz) ?? $start;
$isMultiDay = $end->startOfDay()->gt($start->startOfDay());
return $event->is_all_day || $isMultiDay;
})
->map(function ($event) use ($tz, $weekStart, $weekEnd) {
$start = $event->starts_at->copy()->setTimezone($tz)->startOfDay();
$end = $event->ends_at
? $event->ends_at->copy()->setTimezone($tz)->startOfDay()
: $start;
if ($end->lt($weekStart) || $start->gt($weekEnd)) {
return null;
}
$startClamped = $start->lt($weekStart) ? $weekStart->copy() : $start;
$endClamped = $end->gt($weekEnd) ? $weekEnd->copy() : $end;
$startOffset = $weekStart->diffInDays($startClamped);
$endOffset = $weekStart->diffInDays($endClamped);
$span = ($endOffset - $startOffset) + 1;
return [
'id' => $event->id,
'title' => $event->title,
'color' => $event->color,
'start' => $startClamped,
'end' => $endClamped,
'col' => $startOffset,
'span' => $span,
];
})
->filter()
->sortBy('start')
->values();
return $this->stackAllDayEvents($events);
}
private function stackAllDayEvents($events)
{
$rows = [];
foreach ($events as $event) {
$placed = false;
foreach ($rows as &$row) {
$collision = collect($row)->first(function ($e) use ($event) {
$aStart = $event['col'];
$aEnd = $event['col'] + $event['span'] - 1;
$bStart = $e['col'];
$bEnd = $e['col'] + $e['span'] - 1;
return !($aEnd < $bStart || $aStart > $bEnd);
});
if (!$collision) {
$row[] = $event;
$placed = true;
break;
}
}
if (!$placed) {
$rows[] = [$event];
}
}
return collect($rows)->map(function ($row, $rowIndex) {
return collect($row)->map(function ($event) use ($rowIndex) {
return [
...$event,
'row' => $rowIndex
];
});
})->flatten(1);
}
private function transformEvent($event, $day, $tz)
{
$start = $event->starts_at->copy()->setTimezone($tz);
$end = $event->ends_at?->copy()->setTimezone($tz);
$dayStart = $day->copy()->startOfDay();
$dayEnd = $day->copy()->endOfDay();
// 👉 Seminar / MultiDay Fix
if ($end && $start->diffInDays($end) > 0 && !$event->is_all_day) {
$effectiveStart = $dayStart->copy()->setTime($start->hour, $start->minute);
$effectiveEnd = $dayStart->copy()->setTime($end->hour, $end->minute);
// 👉 Exceptions
$exceptions = is_array($event->exceptions)
? $event->exceptions
: json_decode($event->exceptions ?? '[]', true);
$exception = collect($exceptions)
->firstWhere('date', $day->format('Y-m-d'));
if ($exception) {
if (!empty($exception['start'])) {
[$h, $m] = explode(':', $exception['start']);
$effectiveStart->setTime($h, $m);
}
if (!empty($exception['end'])) {
[$h, $m] = explode(':', $exception['end']);
$effectiveEnd->setTime($h, $m);
}
}
} else {
$effectiveStart = $start;
$effectiveEnd = $end ?? $start->copy()->addHour();
}
$hourHeight = 64;
$top = ($effectiveStart->hour * $hourHeight)
+ (($effectiveStart->minute / 60) * $hourHeight);
$height = max($effectiveStart->diffInMinutes($effectiveEnd), 1) / 60 * $hourHeight;
return [
'model' => $event,
'id' => $event->id,
'title' => $event->title,
'top' => $top,
'height' => $height,
'start' => $effectiveStart,
'end' => $effectiveEnd,
];
}
public function colorToRgba($color, $opacity = 1): string
{
if (!$color) return 'rgba(99,102,241,0.8)'; // fallback
$color = ltrim($color, '#');
$r = hexdec(substr($color, 0, 2));
$g = hexdec(substr($color, 2, 2));
$b = hexdec(substr($color, 4, 2));
return "rgba($r, $g, $b, $opacity)";
}
public function render()
{
return view('livewire.calendar.index')
->layout('layouts.app');
}
}