diff --git a/laravel-app/app/Http/Controllers/ZoneController.php b/laravel-app/app/Http/Controllers/ZoneController.php
index d5d0b2c..b6f6c9a 100644
--- a/laravel-app/app/Http/Controllers/ZoneController.php
+++ b/laravel-app/app/Http/Controllers/ZoneController.php
@@ -3,8 +3,10 @@
namespace App\Http\Controllers;
use App\Http\Requests\ZoneRequest;
+use App\Http\Resources\DeviceResource;
use App\Http\Resources\ZoneResource;
use App\Models\Zone;
+use App\Services\DeviceShadow;
use Inertia\Inertia;
class ZoneController extends Controller
@@ -20,6 +22,20 @@ class ZoneController extends Controller
]);
}
+ public function show(Zone $zone, DeviceShadow $shadow)
+ {
+ $this->authorize('view', $zone);
+
+ $devices = $zone->devices()->with('deviceType')->orderBy('name')->get();
+ $statuses = $shadow->statuses($devices->pluck('external_id')->all());
+ $devices->each(fn ($d) => $d->live_status = $statuses[$d->external_id] ?? 'unknown');
+
+ return Inertia::render('Zones/Show', [
+ 'zone' => (new ZoneResource($zone))->resolve(),
+ 'devices' => DeviceResource::collection($devices)->resolve(),
+ ]);
+ }
+
public function create()
{
$this->authorize('create', Zone::class);
diff --git a/laravel-app/resources/js/Components/DeviceTypeIcon.vue b/laravel-app/resources/js/Components/DeviceTypeIcon.vue
new file mode 100644
index 0000000..fc330a4
--- /dev/null
+++ b/laravel-app/resources/js/Components/DeviceTypeIcon.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
diff --git a/laravel-app/resources/js/Components/StatusDot.vue b/laravel-app/resources/js/Components/StatusDot.vue
new file mode 100644
index 0000000..345a159
--- /dev/null
+++ b/laravel-app/resources/js/Components/StatusDot.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+ {{ label[status] ?? status }}
+
+
diff --git a/laravel-app/resources/js/Pages/Zones/Index.vue b/laravel-app/resources/js/Pages/Zones/Index.vue
index 8f7440d..d6fef9b 100644
--- a/laravel-app/resources/js/Pages/Zones/Index.vue
+++ b/laravel-app/resources/js/Pages/Zones/Index.vue
@@ -34,31 +34,32 @@ function destroy(zone) {
-
-
-
-
- | Название |
- Описание |
- Устройств |
- |
-
-
-
-
- | Зон пока нет. |
-
-
- | {{ zone.name }} |
- {{ zone.description }} |
- {{ zone.devices_count }} |
-
- Изменить
-
- |
-
-
-
+
+ Зон пока нет.
+
+
+
+
+
+
+
+
{{ zone.name }}
+
+ {{ zone.devices_count }} {{ zone.devices_count === 1 ? 'устройство' : 'устройств' }}
+
+
+
+
{{ zone.description }}
+
+
+ Изменить
+
+
+
diff --git a/laravel-app/resources/js/Pages/Zones/Show.vue b/laravel-app/resources/js/Pages/Zones/Show.vue
new file mode 100644
index 0000000..0242f1b
--- /dev/null
+++ b/laravel-app/resources/js/Pages/Zones/Show.vue
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+ ← Зоны
+
{{ zone.name }}
+
+
+ Изменить зону
+
+
+
+
+
+
+
{{ zone.description }}
+
+
+ {{ devices.length }} устройств,
+ {{ onlineCount }} онлайн
+
+
+
+ В этой зоне пока нет устройств.
+
+
+
+
+
+
+
+
+
{{ device.name }}
+
{{ device.device_type.code }}
+
+
+
+
+
+
+ {{ device.external_id }}
+
+
+
+
+
+
+
diff --git a/laravel-app/resources/js/routes.js b/laravel-app/resources/js/routes.js
index 9951aad..70cbfbf 100644
--- a/laravel-app/resources/js/routes.js
+++ b/laravel-app/resources/js/routes.js
@@ -6,6 +6,7 @@ export const routes = {
dashboard: '/dashboard',
zones: {
index: '/zones',
+ show: (id) => `/zones/${id}`,
create: '/zones/create',
store: '/zones',
edit: (id) => `/zones/${id}/edit`,
diff --git a/laravel-app/routes/web.php b/laravel-app/routes/web.php
index 82d7f15..919a69d 100644
--- a/laravel-app/routes/web.php
+++ b/laravel-app/routes/web.php
@@ -30,7 +30,7 @@ Route::get('/dashboard', function (DeviceShadow $shadow) {
})->middleware(['auth', 'verified'])->name('dashboard');
Route::middleware(['auth', 'verified'])->group(function () {
- Route::resource('zones', ZoneController::class)->except('show');
+ Route::resource('zones', ZoneController::class);
Route::resource('devices', DeviceController::class);
Route::resource('automation-rules', AutomationRuleController::class)
->except('show')
diff --git a/laravel-app/tests/Feature/ZoneControllerTest.php b/laravel-app/tests/Feature/ZoneControllerTest.php
index 81ba864..d992af8 100644
--- a/laravel-app/tests/Feature/ZoneControllerTest.php
+++ b/laravel-app/tests/Feature/ZoneControllerTest.php
@@ -2,9 +2,13 @@
namespace Tests\Feature;
+use App\Enums\DeviceCategory;
use App\Enums\UserRole;
+use App\Models\Device;
+use App\Models\DeviceType;
use App\Models\User;
use App\Models\Zone;
+use App\Services\DeviceShadow;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
@@ -52,6 +56,35 @@ class ZoneControllerTest extends TestCase
);
}
+ public function test_zone_show_lists_its_devices_with_live_status(): void
+ {
+ $viewer = User::factory()->create(['role' => UserRole::Viewer]);
+ $zone = Zone::forceCreate(['user_id' => $viewer->id, 'name' => 'Гроубокс']);
+ $type = DeviceType::create([
+ 'code' => 'fan',
+ 'category' => DeviceCategory::Actuator,
+ 'capabilities' => ['turn_on'],
+ ]);
+ Device::forceCreate([
+ 'user_id' => $viewer->id, 'zone_id' => $zone->id, 'device_type_id' => $type->id,
+ 'name' => 'Вентилятор', 'external_id' => 'fan-1', 'protocol' => 'mqtt',
+ ]);
+
+ $this->mock(DeviceShadow::class, function ($mock) {
+ $mock->shouldReceive('statuses')->once()->with(['fan-1'])->andReturn(['fan-1' => 'online']);
+ });
+
+ $response = $this->actingAs($viewer)->get(route('zones.show', $zone));
+
+ $response->assertOk();
+ $response->assertInertia(fn (Assert $page) => $page
+ ->component('Zones/Show')
+ ->where('zone.name', 'Гроубокс')
+ ->where('devices.0.name', 'Вентилятор')
+ ->where('devices.0.status', 'online')
+ );
+ }
+
public function test_viewer_cannot_delete_zone(): void
{
$viewer = User::factory()->create(['role' => UserRole::Viewer]);