Rooms pages: index (per-room status) + show (devices with live control)

Activates the Räume nav tab. Index lists rooms with online/lights/open counts;
show lists a room's devices with entity chips and switch/light toggles (through
DeviceCommandService), live via Echo. Zero console errors (nav check: 4/4 clean).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-18 00:05:30 +02:00
parent aceb140aac
commit f4d8f27db4
9 changed files with 174 additions and 1 deletions

1
.gitignore vendored
View File

@ -35,3 +35,4 @@ Thumbs.db
# generated broker credentials + data (not committed)
/docker/mosquitto/config/passwd
/docker/mosquitto/data/
/.claude/

View File

@ -0,0 +1,25 @@
<?php
namespace App\Livewire\Rooms;
use App\Models\Room;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
#[On('echo-private:home,.DeviceStateChanged')]
public function onDeviceStateChanged(): void {}
public function render()
{
$rooms = Room::query()
->with(['devices.entities.state'])
->orderBy('sort')->orderBy('name')
->get();
return view('livewire.rooms.index', ['rooms' => $rooms]);
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Livewire\Rooms;
use App\Models\Entity;
use App\Models\Room;
use App\Services\DeviceCommandService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.app')]
class Show extends Component
{
public Room $room;
public function mount(Room $room): void
{
$this->room = $room;
}
#[On('echo-private:home,.DeviceStateChanged')]
public function onDeviceStateChanged(): void {}
public function toggleEntity(int $entityId): void
{
$entity = Entity::query()
->whereHas('device', fn ($q) => $q->where('room_id', $this->room->id))
->with('device', 'state')
->find($entityId);
if ($entity === null || ! in_array($entity->type, ['switch', 'light'], true)) {
return;
}
app(DeviceCommandService::class)->toggle($entity);
}
public function render()
{
$this->room->load(['devices' => fn ($q) => $q->orderBy('name'), 'devices.entities.state']);
return view('livewire.rooms.show');
}
}

10
lang/de/rooms.php Normal file
View File

@ -0,0 +1,10 @@
<?php
return [
'subtitle' => 'Alle Räume im Überblick.',
'empty' => 'Noch keine Räume.',
'no_devices' => 'Keine Geräte in diesem Raum.',
'online' => 'online',
'lights' => 'Licht an',
'open' => 'offen',
];

10
lang/en/rooms.php Normal file
View File

@ -0,0 +1,10 @@
<?php
return [
'subtitle' => 'All rooms at a glance.',
'empty' => 'No rooms yet.',
'no_devices' => 'No devices in this room.',
'online' => 'online',
'lights' => 'lights on',
'open' => 'open',
];

View File

@ -2,7 +2,7 @@
$groups = [
'nav.section_overview' => [
['key' => 'dashboard', 'icon' => 'dashboard', 'route' => 'dashboard', 'lock' => false],
['key' => 'rooms', 'icon' => 'rooms', 'route' => null, 'lock' => false],
['key' => 'rooms', 'icon' => 'rooms', 'route' => 'rooms.index', 'active' => 'rooms.*', 'lock' => false],
['key' => 'devices', 'icon' => 'devices', 'route' => 'devices.index', 'active' => 'devices.*', 'lock' => false],
],
'nav.section_persons' => [

View File

@ -0,0 +1,40 @@
<div wire:poll.30s>
<x-topbar :title="__('nav.rooms')" :subtitle="__('rooms.subtitle')" />
<div class="px-5 lg:px-[26px] py-6 flex flex-col gap-5 max-w-[1560px] mx-auto w-full">
@if ($rooms->isEmpty())
<x-panel><p class="text-[13px] text-ink-3 text-center py-6">{{ __('rooms.empty') }}</p></x-panel>
@else
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
@foreach ($rooms as $room)
@php
$devices = $room->devices;
$entities = $devices->flatMap->entities;
$online = $devices->filter->isOnline()->count();
$lightsOn = $entities->filter(fn ($e) => in_array($e->type, ['light', 'switch']) && data_get($e->state, 'state.on') === true)->count();
$open = $entities->filter(fn ($e) => $e->type === 'contact' && data_get($e->state, 'state.open') === true)->count();
@endphp
<a href="{{ route('rooms.show', $room) }}" wire:navigate
class="bg-surface border border-line-soft rounded-card p-4 flex flex-col gap-3 transition-[border-color,transform] duration-200 hover:border-line hover:-translate-y-px">
<div class="flex items-center gap-2.5">
<span class="grid place-items-center w-9 h-9 rounded-lg bg-inset text-ink-2"><x-icon :name="$room->icon ?? 'rooms'" :size="17" /></span>
<span class="text-[14px] font-bold text-ink truncate">{{ $room->name }}</span>
<x-icon name="chevron" :size="15" class="ml-auto text-ink-3" />
</div>
<div class="flex flex-wrap gap-1.5">
<x-status-pill :state="$online === $devices->count() && $devices->count() > 0 ? 'online' : 'neutral'">
<span class="font-mono tabular-nums">{{ $online }}/{{ $devices->count() }}</span> {{ __('rooms.online') }}
</x-status-pill>
@if ($lightsOn > 0)
<x-status-pill state="online"><span class="font-mono">{{ $lightsOn }}</span> {{ __('rooms.lights') }}</x-status-pill>
@endif
@if ($open > 0)
<x-status-pill state="warning"><span class="font-mono">{{ $open }}</span> {{ __('rooms.open') }}</x-status-pill>
@endif
</div>
</a>
@endforeach
</div>
@endif
</div>
</div>

View File

@ -0,0 +1,38 @@
<div wire:poll.30s>
<x-topbar :title="$room->name" :subtitle="__('nav.rooms')">
<x-slot:actions>
<a href="{{ route('rooms.index') }}" wire:navigate
class="inline-flex items-center gap-1.5 rounded-lg border border-line-soft bg-raised px-2.5 py-1.5 text-[12px] font-semibold text-ink-2 hover:text-ink transition-colors">
<x-icon name="chevron" :size="14" class="rotate-180" />
<span class="hidden sm:inline">{{ __('nav.rooms') }}</span>
</a>
</x-slot:actions>
</x-topbar>
<div class="px-5 lg:px-[26px] py-6 flex flex-col gap-4 max-w-[1560px] mx-auto w-full">
@forelse ($room->devices as $device)
<x-panel>
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2.5">
<x-status-dot :state="$device->isOnline() ? 'online' : 'offline'" :pulse="$device->isOnline()" />
<a href="{{ route('devices.show', $device) }}" wire:navigate class="text-[14px] font-bold text-ink hover:text-accent transition-colors truncate">{{ $device->name }}</a>
<span class="ml-auto text-[11px] font-mono text-ink-3 truncate">{{ $device->model }}</span>
</div>
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
@foreach ($device->entities as $entity)
<div class="flex items-center gap-2">
<x-entity-state :entity="$entity" />
@if (in_array($entity->type, ['switch', 'light']))
<x-toggle :on="(bool) data_get($entity->state?->state, 'on', false)"
wire:click="toggleEntity({{ $entity->id }})" :label="$entity->name ?? $entity->key" />
@endif
</div>
@endforeach
</div>
</div>
</x-panel>
@empty
<x-panel><p class="text-[13px] text-ink-3 text-center py-6">{{ __('rooms.no_devices') }}</p></x-panel>
@endforelse
</div>
</div>

View File

@ -5,6 +5,8 @@ use App\Livewire\Dashboard;
use App\Livewire\Devices\Index as DeviceIndex;
use App\Livewire\Devices\Show as DeviceShow;
use App\Livewire\Host;
use App\Livewire\Rooms\Index as RoomIndex;
use App\Livewire\Rooms\Show as RoomShow;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
@ -16,6 +18,8 @@ Route::middleware('guest')->group(function () {
Route::middleware('auth')->group(function () {
Route::get('/dashboard', Dashboard::class)->name('dashboard');
Route::get('/rooms', RoomIndex::class)->name('rooms.index');
Route::get('/rooms/{room}', RoomShow::class)->name('rooms.show');
Route::get('/devices', DeviceIndex::class)->name('devices.index');
Route::get('/devices/{device}', DeviceShow::class)->name('devices.show');
Route::get('/host', Host::class)->name('host');