Compare commits

..
Author SHA1 Message Date
cacto f7aa0ba88e Страница зоны со всеми её устройствами и live-статусом
Раньше зона в списке была просто строкой без возможности провалиться
внутрь — все устройства смотрелись только через общий плоский список
/devices с колонкой "Зона". Теперь клик по зоне открывает её страницу
с карточками устройств (иконка по категории, статус online/offline/
unknown точкой с пульсацией для online, external_id) — ровно то, что
просили: все устройства и их статус на одном экране при выборе зоны.

Заодно список зон переведён с голой таблицы на карточки для
консистентности с новой страницей — вся карточка кликабельна и ведёт
на show, кнопки изменить/удалить остаются только у owner.

ZonePolicy::view уже был публичным (true всем) — новый роут
zones.show использует его без изменений.
2026-08-13 13:07:01 +05:00
cacto be1f238f89 Перевод защищённой части приложения на Vue.js + Inertia
Дашборд, зоны, устройства, правила автоматизации и профиль теперь
Vue 3 + Inertia вместо Blade+Alpine — гостевые страницы Breeze
(логин/регистрация) сознательно оставлены на Blade, чтобы не трогать
уже протестированную аутентификацию. Контроллеры возвращают
Inertia::render() вместо view(), переиспользуют существующие
API Resources и FormRequest.

Ключевой нюанс: Resource/ResourceCollection, переданные напрямую в
проп Inertia, заворачиваются в ключ "data" (Inertia вызывает
toResponse() у Responsable-объектов) — везде используется ->resolve()
и явное резолвление вложенных ресурсов (zone/device_type внутри
Device и т.п.), иначе вложенные поля тоже задваивались бы обёрткой.

Alpine.js и все Blade-вьюхи защищённой части удалены как мёртвый код.
Тесты (ZoneControllerTest/DeviceControllerTest) переведены на
assertInertia(). UserFactory теперь явно задаёт role=owner — без
этого Eloquent не подтягивает DB-default обратно в модель после
create(), что уронило бы общий auth.user проп на любой странице.
2026-08-12 10:52:04 +05:00
cacto e739ef5d38 Добавлен REST API для мобильного приложения (этап 3, без Telegram)
Токен-аутентификация Sanctum (login/logout по устройствам),
эндпоинты /api/v1 для зон/устройств/правил автоматизации с тем же
RBAC (owner/viewer), что и в веб-версии — контроллеры переиспользуют
существующие Policy и FormRequest. Устройства отдают живой статус
из Device Shadow (Redis) и историю телеметрии из ClickHouse, плюс
управление (turn-on/turn-off/set-level) через device-control-service.
2026-08-12 00:21:38 +05:00
87 changed files with 3034 additions and 1232 deletions
@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
/**
* Issue a Sanctum personal access token for a mobile client. Each
* client names its own token (device_name) so a user can see/revoke
* per-device sessions later without logging everyone out at once.
*/
public function login(Request $request)
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
'device_name' => ['required', 'string', 'max:255'],
]);
$user = User::where('email', $credentials['email'])->first();
if (! $user || ! Hash::check($credentials['password'], $user->password)) {
throw ValidationException::withMessages([
'email' => ['Неверный email или пароль.'],
]);
}
return response()->json([
'token' => $user->createToken($credentials['device_name'])->plainTextToken,
'user' => new UserResource($user),
]);
}
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->noContent();
}
public function me(Request $request)
{
return new UserResource($request->user());
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\AutomationRuleRequest;
use App\Http\Resources\AutomationRuleResource;
use App\Models\AutomationRule;
class AutomationRuleController extends Controller
{
public function index()
{
$this->authorize('viewAny', AutomationRule::class);
$rules = AutomationRule::with(['zone', 'targetDevice', 'conditionSourceDevice'])
->orderByDesc('id')
->get();
return AutomationRuleResource::collection($rules);
}
public function store(AutomationRuleRequest $request)
{
$rule = new AutomationRule($this->mapActionParams($request->validated()));
$rule->user()->associate($request->user());
$rule->save();
return (new AutomationRuleResource($rule))->response()->setStatusCode(201);
}
public function update(AutomationRuleRequest $request, AutomationRule $automation_rule)
{
$automation_rule->update($this->mapActionParams($request->validated()));
return new AutomationRuleResource($automation_rule);
}
public function destroy(AutomationRule $automation_rule)
{
$this->authorize('delete', $automation_rule);
$automation_rule->delete();
return response()->noContent();
}
/**
* Mirrors AutomationRuleController@mapActionParams on the web side
* "level" is a friendlier stand-in for action_params over the wire too,
* so mobile clients don't need to know the {"level": ...} shape.
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function mapActionParams(array $data): array
{
$data['action_params'] = $data['action_type'] === 'set_level'
? ['level' => $data['level']]
: [];
unset($data['level']);
return $data;
}
}
@@ -0,0 +1,126 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\DeviceRequest;
use App\Http\Resources\DeviceResource;
use App\Models\Device;
use App\Services\ClickHouseClient;
use App\Services\DeviceControlClient;
use App\Services\DeviceShadow;
use Illuminate\Http\Request;
class DeviceController extends Controller
{
public function index(DeviceShadow $shadow)
{
$this->authorize('viewAny', Device::class);
$devices = Device::with(['zone', 'deviceType'])->orderBy('name')->get();
$statuses = $shadow->statuses($devices->pluck('external_id')->all());
$devices->each(fn (Device $d) => $d->live_status = $statuses[$d->external_id] ?? 'unknown');
return DeviceResource::collection($devices);
}
public function show(Device $device, DeviceShadow $shadow, ClickHouseClient $clickHouse)
{
$this->authorize('view', $device);
$device->load(['zone', 'deviceType']);
$snapshot = $shadow->snapshot($device->external_id);
$device->live_status = $snapshot['status'];
$telemetry = [];
if ($device->deviceType->category->value === 'sensor') {
$telemetry = $clickHouse->query(
'SELECT sensor_type, value, recorded_at FROM telemetry
WHERE device_id = {device_id:String}
ORDER BY recorded_at DESC LIMIT 50',
['device_id' => $device->external_id],
);
}
return (new DeviceResource($device))->additional([
'shadow' => [
'last_seen' => $snapshot['last_seen'],
'desired_state' => $snapshot['desired_state'],
'reported_state' => $snapshot['reported_state'],
],
'telemetry' => $telemetry,
]);
}
public function store(DeviceRequest $request)
{
$device = new Device($request->validated());
$device->user()->associate($request->user());
$device->save();
return (new DeviceResource($device))->response()->setStatusCode(201);
}
public function update(DeviceRequest $request, Device $device)
{
$device->update($request->validated());
return new DeviceResource($device);
}
public function destroy(Device $device)
{
$this->authorize('delete', $device);
$device->delete();
return response()->noContent();
}
public function turnOn(Device $device, DeviceControlClient $client)
{
$this->authorize('update', $device);
$this->ensureCapability($device, 'turn_on');
return $this->respondToCommand($client->turnOn($device->external_id));
}
public function turnOff(Device $device, DeviceControlClient $client)
{
$this->authorize('update', $device);
$this->ensureCapability($device, 'turn_off');
return $this->respondToCommand($client->turnOff($device->external_id));
}
public function setLevel(Request $request, Device $device, DeviceControlClient $client)
{
$this->authorize('update', $device);
$this->ensureCapability($device, 'set_level');
$validated = $request->validate(['level' => ['required', 'numeric']]);
return $this->respondToCommand($client->setLevel($device->external_id, (float) $validated['level']));
}
private function ensureCapability(Device $device, string $capability): void
{
abort_unless(
in_array($capability, $device->deviceType->capabilities ?? [], true),
422,
"Устройство не поддерживает действие «{$capability}».",
);
}
/**
* @param array{success: bool, error: ?string} $result
*/
private function respondToCommand(array $result)
{
if (! $result['success']) {
return response()->json(['message' => $result['error'] ?? 'Команда не выполнена.'], 422);
}
return response()->json(['message' => 'Команда отправлена.']);
}
}
@@ -0,0 +1,15 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\DeviceTypeResource;
use App\Models\DeviceType;
class DeviceTypeController extends Controller
{
public function index()
{
return DeviceTypeResource::collection(DeviceType::orderBy('code')->get());
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\ZoneRequest;
use App\Http\Resources\ZoneResource;
use App\Models\Zone;
class ZoneController extends Controller
{
public function index()
{
$this->authorize('viewAny', Zone::class);
return ZoneResource::collection(Zone::withCount('devices')->orderBy('name')->get());
}
public function store(ZoneRequest $request)
{
$zone = $request->user()->zones()->create($request->validated());
return (new ZoneResource($zone))->response()->setStatusCode(201);
}
public function update(ZoneRequest $request, Zone $zone)
{
$zone->update($request->validated());
return new ZoneResource($zone);
}
public function destroy(Zone $zone)
{
$this->authorize('delete', $zone);
$zone->delete();
return response()->noContent();
}
}
@@ -2,12 +2,18 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Enums\ConditionOperator;
use App\Enums\DeviceCategory; use App\Enums\DeviceCategory;
use App\Http\Requests\AutomationRuleRequest; use App\Http\Requests\AutomationRuleRequest;
use App\Http\Resources\AutomationRuleResource;
use App\Http\Resources\DeviceResource;
use App\Http\Resources\ZoneResource;
use App\Models\AutomationRule; use App\Models\AutomationRule;
use App\Models\Device; use App\Models\Device;
use App\Models\DeviceType; use App\Models\DeviceType;
use App\Models\Zone; use App\Models\Zone;
use Illuminate\Support\Collection;
use Inertia\Inertia;
class AutomationRuleController extends Controller class AutomationRuleController extends Controller
{ {
@@ -19,14 +25,16 @@ class AutomationRuleController extends Controller
->orderByDesc('id') ->orderByDesc('id')
->get(); ->get();
return view('automation-rules.index', ['rules' => $rules]); return Inertia::render('AutomationRules/Index', [
'rules' => AutomationRuleResource::collection($rules)->resolve(),
]);
} }
public function create() public function create()
{ {
$this->authorize('create', AutomationRule::class); $this->authorize('create', AutomationRule::class);
return view('automation-rules.create', $this->formOptions()); return Inertia::render('AutomationRules/Create', $this->formOptions());
} }
public function store(AutomationRuleRequest $request) public function store(AutomationRuleRequest $request)
@@ -42,8 +50,19 @@ class AutomationRuleController extends Controller
{ {
$this->authorize('update', $automation_rule); $this->authorize('update', $automation_rule);
return view('automation-rules.edit', [ return Inertia::render('AutomationRules/Edit', [
'rule' => $automation_rule, 'rule' => [
'id' => $automation_rule->id,
'zone_id' => $automation_rule->zone_id,
'condition_source_device_id' => $automation_rule->condition_source_device_id,
'condition_sensor_type' => $automation_rule->condition_sensor_type,
'condition_operator' => $automation_rule->condition_operator->value,
'condition_value' => $automation_rule->condition_value,
'target_device_id' => $automation_rule->target_device_id,
'action_type' => $automation_rule->action_type,
'level' => $automation_rule->action_params['level'] ?? '',
'is_active' => $automation_rule->is_active,
],
...$this->formOptions(), ...$this->formOptions(),
]); ]);
} }
@@ -91,10 +110,11 @@ class AutomationRuleController extends Controller
$devices = Device::with(['zone', 'deviceType'])->orderBy('name')->get(); $devices = Device::with(['zone', 'deviceType'])->orderBy('name')->get();
return [ return [
'zones' => Zone::orderBy('name')->get(), 'zones' => ZoneResource::collection(Zone::orderBy('name')->get())->resolve(),
'devices' => $devices, 'devices' => DeviceResource::collection($devices)->resolve(),
'actionTypeOptions' => $this->capabilityOptions(DeviceCategory::Actuator), 'actionTypeOptions' => $this->capabilityOptions(DeviceCategory::Actuator),
'sensorTypeOptions' => $this->capabilityOptions(DeviceCategory::Sensor), 'sensorTypeOptions' => $this->capabilityOptions(DeviceCategory::Sensor),
'conditionOperatorOptions' => array_map(fn ($case) => $case->value, ConditionOperator::cases()),
]; ];
} }
@@ -102,7 +122,7 @@ class AutomationRuleController extends Controller
* Distinct capability values across device_types of the given category * Distinct capability values across device_types of the given category
* used as friendly select/datalist suggestions, not a hardcoded list. * used as friendly select/datalist suggestions, not a hardcoded list.
* *
* @return \Illuminate\Support\Collection<int, string> * @return Collection<int, string>
*/ */
private function capabilityOptions(DeviceCategory $category) private function capabilityOptions(DeviceCategory $category)
{ {
@@ -3,6 +3,9 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Requests\DeviceRequest; use App\Http\Requests\DeviceRequest;
use App\Http\Resources\DeviceResource;
use App\Http\Resources\DeviceTypeResource;
use App\Http\Resources\ZoneResource;
use App\Models\Device; use App\Models\Device;
use App\Models\DeviceType; use App\Models\DeviceType;
use App\Models\Zone; use App\Models\Zone;
@@ -10,6 +13,7 @@ use App\Services\ClickHouseClient;
use App\Services\DeviceControlClient; use App\Services\DeviceControlClient;
use App\Services\DeviceShadow; use App\Services\DeviceShadow;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia;
class DeviceController extends Controller class DeviceController extends Controller
{ {
@@ -19,8 +23,11 @@ class DeviceController extends Controller
$devices = Device::with(['zone', 'deviceType'])->orderBy('name')->get(); $devices = Device::with(['zone', 'deviceType'])->orderBy('name')->get();
$statuses = $shadow->statuses($devices->pluck('external_id')->all()); $statuses = $shadow->statuses($devices->pluck('external_id')->all());
$devices->each(fn (Device $d) => $d->live_status = $statuses[$d->external_id] ?? 'unknown');
return view('devices.index', compact('devices', 'statuses')); return Inertia::render('Devices/Index', [
'devices' => DeviceResource::collection($devices)->resolve(),
]);
} }
public function show(Device $device, DeviceShadow $shadow, ClickHouseClient $clickHouse) public function show(Device $device, DeviceShadow $shadow, ClickHouseClient $clickHouse)
@@ -28,6 +35,8 @@ class DeviceController extends Controller
$this->authorize('view', $device); $this->authorize('view', $device);
$device->load(['zone', 'deviceType']); $device->load(['zone', 'deviceType']);
$snapshot = $shadow->snapshot($device->external_id);
$device->live_status = $snapshot['status'];
$telemetry = []; $telemetry = [];
if ($device->deviceType->category->value === 'sensor') { if ($device->deviceType->category->value === 'sensor') {
@@ -39,9 +48,13 @@ class DeviceController extends Controller
); );
} }
return view('devices.show', [ return Inertia::render('Devices/Show', [
'device' => $device, 'device' => (new DeviceResource($device))->resolve(),
'shadow' => $shadow->snapshot($device->external_id), 'shadow' => [
'last_seen' => $snapshot['last_seen'],
'desired_state' => $snapshot['desired_state'],
'reported_state' => $snapshot['reported_state'],
],
'telemetry' => $telemetry, 'telemetry' => $telemetry,
]); ]);
} }
@@ -50,7 +63,7 @@ class DeviceController extends Controller
{ {
$this->authorize('create', Device::class); $this->authorize('create', Device::class);
return view('devices.create', $this->formOptions()); return Inertia::render('Devices/Create', $this->formOptions());
} }
public function store(DeviceRequest $request) public function store(DeviceRequest $request)
@@ -66,7 +79,10 @@ class DeviceController extends Controller
{ {
$this->authorize('update', $device); $this->authorize('update', $device);
return view('devices.edit', ['device' => $device, ...$this->formOptions()]); return Inertia::render('Devices/Edit', [
'device' => $device->only(['id', 'name', 'zone_id', 'device_type_id', 'external_id', 'protocol']),
...$this->formOptions(),
]);
} }
public function update(DeviceRequest $request, Device $device) public function update(DeviceRequest $request, Device $device)
@@ -137,8 +153,8 @@ class DeviceController extends Controller
private function formOptions(): array private function formOptions(): array
{ {
return [ return [
'zones' => Zone::orderBy('name')->get(), 'zones' => ZoneResource::collection(Zone::orderBy('name')->get())->resolve(),
'deviceTypes' => DeviceType::orderBy('code')->get(), 'deviceTypes' => DeviceTypeResource::collection(DeviceType::orderBy('code')->get())->resolve(),
]; ];
} }
} }
@@ -7,17 +7,18 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect; use Illuminate\Support\Facades\Redirect;
use Illuminate\View\View; use Inertia\Inertia;
use Inertia\Response;
class ProfileController extends Controller class ProfileController extends Controller
{ {
/** /**
* Display the user's profile form. * Display the user's profile form.
*/ */
public function edit(Request $request): View public function edit(Request $request): Response
{ {
return view('profile.edit', [ return Inertia::render('Profile/Edit', [
'user' => $request->user(), 'user' => $request->user()->only(['id', 'name', 'email']),
]); ]);
} }
@@ -3,7 +3,11 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Requests\ZoneRequest; use App\Http\Requests\ZoneRequest;
use App\Http\Resources\DeviceResource;
use App\Http\Resources\ZoneResource;
use App\Models\Zone; use App\Models\Zone;
use App\Services\DeviceShadow;
use Inertia\Inertia;
class ZoneController extends Controller class ZoneController extends Controller
{ {
@@ -13,14 +17,30 @@ class ZoneController extends Controller
$zones = Zone::withCount('devices')->orderBy('name')->get(); $zones = Zone::withCount('devices')->orderBy('name')->get();
return view('zones.index', compact('zones')); return Inertia::render('Zones/Index', [
'zones' => ZoneResource::collection($zones)->resolve(),
]);
}
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() public function create()
{ {
$this->authorize('create', Zone::class); $this->authorize('create', Zone::class);
return view('zones.create'); return Inertia::render('Zones/Create');
} }
public function store(ZoneRequest $request) public function store(ZoneRequest $request)
@@ -34,7 +54,9 @@ class ZoneController extends Controller
{ {
$this->authorize('update', $zone); $this->authorize('update', $zone);
return view('zones.edit', compact('zone')); return Inertia::render('Zones/Edit', [
'zone' => (new ZoneResource($zone))->resolve(),
]);
} }
public function update(ZoneRequest $request, Zone $zone) public function update(ZoneRequest $request, Zone $zone)
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Middleware;
use App\Http\Resources\UserResource;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
protected $rootView = 'app';
public function version(Request $request): ?string
{
return parent::version($request);
}
/** @return array<string, mixed> */
public function share(Request $request): array
{
return [
...parent::share($request),
'auth' => [
// ->resolve() instead of passing the Resource directly: Inertia
// calls ->toResponse() on Responsable props (Resources are
// Responsable), which triggers Laravel's `data`-key wrapping —
// resolve() returns the plain array Inertia pages actually want.
'user' => $request->user() ? (new UserResource($request->user()))->resolve() : null,
],
'flash' => [
'status' => fn () => $request->session()->get('status'),
'error' => fn () => $request->session()->get('error'),
],
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Resources;
use App\Models\AutomationRule;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin AutomationRule */
class AutomationRuleResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
// See DeviceResource for why nested resources are ->resolve()'d
// rather than returned as bare Resource instances.
'zone' => $this->whenLoaded('zone', fn ($zone) => (new ZoneResource($zone))->resolve()),
'target_device' => $this->whenLoaded('targetDevice', fn ($device) => (new DeviceResource($device))->resolve()),
'condition_source_device' => $this->whenLoaded('conditionSourceDevice', fn ($device) => (new DeviceResource($device))->resolve()),
'condition_sensor_type' => $this->condition_sensor_type,
'condition_operator' => $this->condition_operator->value,
'condition_value' => $this->condition_value,
'action_type' => $this->action_type,
'action_params' => $this->action_params,
'is_active' => $this->is_active,
'created_at' => $this->created_at,
];
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Resources;
use App\Models\Device;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Device
*
* `devices.status` in Postgres is only a stale snapshot (see migration
* comment) Redis (Device Shadow) is authoritative. The controller stamps
* the live value onto the model as `live_status` before wrapping it here;
* `status` in the JSON always prefers that when present.
*/
class DeviceResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'external_id' => $this->external_id,
'protocol' => $this->protocol,
'status' => $this->live_status ?? $this->status,
// Nested resources are resolved (not returned as bare Resource
// instances) — a Resource embedded as a value gets wrapped in its
// own `data` key when the parent response goes through Laravel's
// Responsable pipeline (Inertia props, ->response() in the API),
// which would double up with the outer collection's own wrapping.
'zone' => $this->whenLoaded('zone', fn ($zone) => (new ZoneResource($zone))->resolve()),
'device_type' => $this->whenLoaded('deviceType', fn ($type) => (new DeviceTypeResource($type))->resolve()),
'created_at' => $this->created_at,
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Resources;
use App\Models\DeviceType;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin DeviceType */
class DeviceTypeResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'code' => $this->code,
'category' => $this->category->value,
'capabilities' => $this->capabilities,
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Resources;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin User */
class UserResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'role' => $this->role->value,
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources;
use App\Models\Zone;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Zone */
class ZoneResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'devices_count' => $this->whenCounted('devices'),
'created_at' => $this->created_at,
];
}
}
@@ -2,6 +2,7 @@
namespace App\Services; namespace App\Services;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -43,7 +44,7 @@ class DeviceControlClient
{ {
try { try {
$response = Http::timeout(5)->post("{$this->baseUrl}{$path}", $body); $response = Http::timeout(5)->post("{$this->baseUrl}{$path}", $body);
} catch (\Illuminate\Http\Client\ConnectionException $e) { } catch (ConnectionException $e) {
Log::error('device-control-service unreachable', ['path' => $path, 'error' => $e->getMessage()]); Log::error('device-control-service unreachable', ['path' => $path, 'error' => $e->getMessage()]);
return ['success' => false, 'error' => 'device-control-service недоступен']; return ['success' => false, 'error' => 'device-control-service недоступен'];
@@ -1,17 +0,0 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class AppLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render(): View
{
return view('layouts.app');
}
}
+4 -1
View File
@@ -1,5 +1,6 @@
<?php <?php
use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
@@ -13,7 +14,9 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// $middleware->web(append: [
HandleInertiaRequests::class,
]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen( $exceptions->shouldRenderJsonWhen(
+1
View File
@@ -7,6 +7,7 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.3", "php": "^8.3",
"inertiajs/inertia-laravel": "^3.3",
"laravel/framework": "^13.8", "laravel/framework": "^13.8",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
+73 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "483f1bf17688fcc178b8c1849ff31ddc", "content-hash": "3c4f6c9d2bddd0693e70d6396ea65c43",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@@ -1057,6 +1057,78 @@
], ],
"time": "2026-07-17T13:53:03+00:00" "time": "2026-07-17T13:53:03+00:00"
}, },
{
"name": "inertiajs/inertia-laravel",
"version": "v3.3.1",
"source": {
"type": "git",
"url": "https://github.com/inertiajs/inertia-laravel.git",
"reference": "7bfd75e352938b703180574b943d81963555a0aa"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/7bfd75e352938b703180574b943d81963555a0aa",
"reference": "7bfd75e352938b703180574b943d81963555a0aa",
"shasum": ""
},
"require": {
"ext-json": "*",
"laravel/framework": "^11.35|^12.0|^13.0",
"php": "^8.2.0",
"symfony/console": "^7.0|^8.0"
},
"conflict": {
"laravel/boost": "<2.5.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.15.2|^8.0",
"larastan/larastan": "^3.0",
"laravel/pint": "^1.16",
"mockery/mockery": "^1.3.3",
"orchestra/testbench": "^9.2|^10.0|^11.0",
"phpunit/phpunit": "^11.5|^12.0"
},
"suggest": {
"ext-pcntl": "Recommended when running the Inertia SSR server via the `inertia:start-ssr` artisan command."
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Inertia\\ServiceProvider"
]
}
},
"autoload": {
"files": [
"./helpers.php"
],
"psr-4": {
"Inertia\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jonathan Reinink",
"email": "jonathan@reinink.ca",
"homepage": "https://reinink.ca"
}
],
"description": "The Laravel adapter for Inertia.js.",
"keywords": [
"inertia",
"laravel"
],
"support": {
"issues": "https://github.com/inertiajs/inertia-laravel/issues",
"source": "https://github.com/inertiajs/inertia-laravel/tree/v3.3.1"
},
"time": "2026-08-04T21:58:51+00:00"
},
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "v13.22.0", "version": "v13.22.0",
@@ -2,6 +2,7 @@
namespace Database\Factories; namespace Database\Factories;
use App\Enums\UserRole;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@@ -30,6 +31,10 @@ class UserFactory extends Factory
'email_verified_at' => now(), 'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'), 'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10), 'remember_token' => Str::random(10),
// Matches the `role` column's DB default — create() doesn't
// hydrate DB-side defaults back into the in-memory model, so an
// omitted role here would leave $user->role null in-process.
'role' => UserRole::Owner,
]; ];
} }
+317 -73
View File
@@ -4,10 +4,14 @@
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"dependencies": {
"@inertiajs/vue3": "^3.6.1",
"@vitejs/plugin-vue": "^6.0.8",
"vue": "^3.5.41"
},
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.2", "@tailwindcss/forms": "^0.5.2",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"alpinejs": "^3.4.2",
"autoprefixer": "^10.4.2", "autoprefixer": "^10.4.2",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"laravel-vite-plugin": "^3.1", "laravel-vite-plugin": "^3.1",
@@ -29,11 +33,56 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.8"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -45,7 +94,6 @@
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -56,13 +104,45 @@
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@inertiajs/core": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-3.6.1.tgz",
"integrity": "sha512-h6+qqkKfpcoZvxWENy/F5yyiD00PIm8lK+ruQkt6HGk4d3N0LMS9GeUbWVQ4+TDZzIxP/vQLurPktnYvDhYeew==",
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"es-toolkit": "^1.33.0",
"laravel-precognition": "^2.0.0"
},
"peerDependencies": {
"axios": "^1.15.2"
},
"peerDependenciesMeta": {
"axios": {
"optional": true
}
}
},
"node_modules/@inertiajs/vue3": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-3.6.1.tgz",
"integrity": "sha512-7M76W7uw1DqxWR5O+OV7Yg23yITTDcRxuO7KCdQhI58rp9+OTv2BZId7ePSBmqahYOeXUZbTfZfVoS2O6/hijA==",
"license": "MIT",
"dependencies": {
"@inertiajs/core": "3.6.1",
"es-toolkit": "^1.33.0",
"laravel-precognition": "^2.0.0"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -89,7 +169,6 @@
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
@@ -99,14 +178,12 @@
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": { "node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31", "version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/resolve-uri": "^3.1.0",
@@ -117,7 +194,6 @@
"version": "1.1.6", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -174,7 +250,6 @@
"version": "0.139.0", "version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/Boshen" "url": "https://github.com/sponsors/Boshen"
@@ -187,7 +262,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -204,7 +278,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -221,7 +294,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -238,7 +310,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -255,7 +326,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -272,7 +342,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -289,7 +358,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -306,7 +374,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -323,7 +390,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -340,7 +406,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -357,7 +422,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -374,7 +438,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -391,7 +454,6 @@
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -410,7 +472,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -427,7 +488,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -441,7 +501,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tailwindcss/forms": { "node_modules/@tailwindcss/forms": {
@@ -747,40 +806,171 @@
"version": "0.10.3", "version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@vue/reactivity": { "node_modules/@vitejs/plugin-vue": {
"version": "3.1.5", "version": "6.0.8",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz",
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==", "integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/shared": "3.1.5" "@rolldown/pluginutils": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"vue": "^3.2.25"
} }
}, },
"node_modules/@vue/shared": { "node_modules/@vue/compiler-core": {
"version": "3.1.5", "version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz",
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==", "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==",
"dev": true, "license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.8",
"@vue/shared": "3.5.41",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-core/node_modules/@vue/shared": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz",
"integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/alpinejs": { "node_modules/@vue/compiler-dom": {
"version": "3.15.12", "version": "3.5.41",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz",
"integrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==", "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/reactivity": "~3.1.1" "@vue/compiler-core": "3.5.41",
"@vue/shared": "3.5.41"
} }
}, },
"node_modules/@vue/compiler-dom/node_modules/@vue/shared": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz",
"integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==",
"license": "MIT"
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz",
"integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.8",
"@vue/compiler-core": "3.5.41",
"@vue/compiler-dom": "3.5.41",
"@vue/compiler-ssr": "3.5.41",
"@vue/shared": "3.5.41",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.19",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-sfc/node_modules/@vue/shared": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz",
"integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==",
"license": "MIT"
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz",
"integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.41",
"@vue/shared": "3.5.41"
}
},
"node_modules/@vue/compiler-ssr/node_modules/@vue/shared": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz",
"integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==",
"license": "MIT"
},
"node_modules/@vue/runtime-core": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz",
"integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.41",
"@vue/shared": "3.5.41"
}
},
"node_modules/@vue/runtime-core/node_modules/@vue/reactivity": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz",
"integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.41"
}
},
"node_modules/@vue/runtime-core/node_modules/@vue/shared": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz",
"integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==",
"license": "MIT"
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz",
"integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.41",
"@vue/runtime-core": "3.5.41",
"@vue/shared": "3.5.41",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/runtime-dom/node_modules/@vue/reactivity": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz",
"integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.41"
}
},
"node_modules/@vue/runtime-dom/node_modules/@vue/shared": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz",
"integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==",
"license": "MIT"
},
"node_modules/@vue/server-renderer": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz",
"integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.41",
"@vue/runtime-dom": "3.5.41",
"@vue/shared": "3.5.41"
}
},
"node_modules/@vue/server-renderer/node_modules/@vue/shared": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz",
"integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==",
"license": "MIT"
},
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -1127,11 +1317,16 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -1179,6 +1374,18 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-errors": { "node_modules/es-errors": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
@@ -1189,6 +1396,17 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-toolkit": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz",
"integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks",
"tests/types"
]
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1199,6 +1417,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -1270,7 +1494,6 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -1420,12 +1643,29 @@
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/laravel-precognition": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-2.0.0.tgz",
"integrity": "sha512-dmA4HGc9m+TsVNsJs9/XQBI8u6j7coilN+qKkBuhuXQzH3HypwS/c5dFQ4UqUGjBbcxIM7zdk91kM/SRZwIvWQ==",
"license": "MIT",
"dependencies": {
"es-toolkit": "^1.32.0"
},
"peerDependencies": {
"axios": "^1.4.0"
},
"peerDependenciesMeta": {
"axios": {
"optional": true
}
}
},
"node_modules/laravel-vite-plugin": { "node_modules/laravel-vite-plugin": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.3.tgz", "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.3.tgz",
@@ -1457,7 +1697,6 @@
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"dependencies": { "dependencies": {
"detect-libc": "^2.0.3" "detect-libc": "^2.0.3"
@@ -1490,7 +1729,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1511,7 +1749,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1532,7 +1769,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1553,7 +1789,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1574,7 +1809,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1595,7 +1829,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1616,7 +1849,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1637,7 +1869,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1658,7 +1889,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1679,7 +1909,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1700,7 +1929,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1738,7 +1966,6 @@
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
@@ -1791,10 +2018,9 @@
} }
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.16", "version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -1860,7 +2086,6 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
@@ -1900,7 +2125,6 @@
"version": "8.5.23", "version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -2150,7 +2374,6 @@
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.139.0", "@oxc-project/types": "=0.139.0",
@@ -2231,7 +2454,6 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -2406,7 +2628,6 @@
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -2423,7 +2644,6 @@
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@@ -2441,7 +2661,6 @@
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -2484,7 +2703,7 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true, "devOptional": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
@@ -2529,7 +2748,6 @@
"version": "8.1.5", "version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
@@ -2618,7 +2836,6 @@
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -2627,6 +2844,33 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/vue": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz",
"integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.41",
"@vue/compiler-sfc": "3.5.41",
"@vue/runtime-dom": "3.5.41",
"@vue/server-renderer": "3.5.41",
"@vue/shared": "3.5.41"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/vue/node_modules/@vue/shared": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz",
"integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==",
"license": "MIT"
},
"node_modules/wrap-ansi": { "node_modules/wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+5 -1
View File
@@ -9,12 +9,16 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.2", "@tailwindcss/forms": "^0.5.2",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"alpinejs": "^3.4.2",
"autoprefixer": "^10.4.2", "autoprefixer": "^10.4.2",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"laravel-vite-plugin": "^3.1", "laravel-vite-plugin": "^3.1",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"tailwindcss": "^3.1.0", "tailwindcss": "^3.1.0",
"vite": "^8.0.0" "vite": "^8.0.0"
},
"dependencies": {
"@inertiajs/vue3": "^3.6.1",
"@vitejs/plugin-vue": "^6.0.8",
"vue": "^3.5.41"
} }
} }
@@ -0,0 +1,11 @@
<script setup>
defineProps({
class: { type: String, default: '' },
});
</script>
<template>
<svg viewBox="0 0 316 316" xmlns="http://www.w3.org/2000/svg" :class="class">
<path d="M305.8 81.125C305.77 80.995 305.69 80.885 305.65 80.755C305.56 80.525 305.49 80.285 305.37 80.075C305.29 79.935 305.17 79.815 305.07 79.685C304.94 79.515 304.83 79.325 304.68 79.175C304.55 79.045 304.39 78.955 304.25 78.845C304.09 78.715 303.95 78.575 303.77 78.475L251.32 48.275C249.97 47.495 248.31 47.495 246.96 48.275L194.51 78.475C194.33 78.575 194.19 78.725 194.03 78.845C193.89 78.955 193.73 79.045 193.6 79.175C193.45 79.325 193.34 79.515 193.21 79.685C193.11 79.815 192.99 79.935 192.91 80.075C192.79 80.285 192.71 80.525 192.63 80.755C192.58 80.875 192.51 80.995 192.48 81.125C192.38 81.495 192.33 81.875 192.33 82.265V139.625L148.62 164.795V52.575C148.62 52.185 148.57 51.805 148.47 51.435C148.44 51.305 148.36 51.195 148.32 51.065C148.23 50.835 148.16 50.595 148.04 50.385C147.96 50.245 147.84 50.125 147.74 49.995C147.61 49.825 147.5 49.635 147.35 49.485C147.22 49.355 147.06 49.265 146.92 49.155C146.76 49.025 146.62 48.885 146.44 48.785L93.99 18.585C92.64 17.805 90.98 17.805 89.63 18.585L37.18 48.785C37 48.885 36.86 49.035 36.7 49.155C36.56 49.265 36.4 49.355 36.27 49.485C36.12 49.635 36.01 49.825 35.88 49.995C35.78 50.125 35.66 50.245 35.58 50.385C35.46 50.595 35.38 50.835 35.3 51.065C35.25 51.185 35.18 51.305 35.15 51.435C35.05 51.805 35 52.185 35 52.575V232.235C35 233.795 35.84 235.245 37.19 236.025L142.1 296.425C142.33 296.555 142.58 296.635 142.82 296.725C142.93 296.765 143.04 296.835 143.16 296.865C143.53 296.965 143.9 297.015 144.28 297.015C144.66 297.015 145.03 296.965 145.4 296.865C145.5 296.835 145.59 296.775 145.69 296.745C145.95 296.655 146.21 296.565 146.45 296.435L251.36 236.035C252.72 235.255 253.55 233.815 253.55 232.245V174.885L303.81 145.945C305.17 145.165 306 143.725 306 142.155V82.265C305.95 81.875 305.89 81.495 305.8 81.125ZM144.2 227.205L100.57 202.515L146.39 176.135L196.66 147.195L240.33 172.335L208.29 190.625L144.2 227.205ZM244.75 114.995V164.795L226.39 154.225L201.03 139.625V89.825L219.39 100.395L244.75 114.995ZM249.12 57.105L292.81 82.265L249.12 107.425L205.43 82.265L249.12 57.105ZM114.49 184.425L96.13 194.995V85.305L121.49 70.705L139.85 60.135V169.815L114.49 184.425ZM91.76 27.425L135.45 52.585L91.76 77.745L48.07 52.585L91.76 27.425ZM43.67 60.135L62.03 70.705L87.39 85.305V202.545V202.555V202.565C87.39 202.735 87.44 202.895 87.46 203.055C87.49 203.265 87.49 203.485 87.55 203.695V203.705C87.6 203.875 87.69 204.035 87.76 204.195C87.84 204.375 87.89 204.575 87.99 204.745C87.99 204.745 87.99 204.755 88 204.755C88.09 204.905 88.22 205.035 88.33 205.175C88.45 205.335 88.55 205.495 88.69 205.635L88.7 205.645C88.82 205.765 88.98 205.855 89.12 205.965C89.28 206.085 89.42 206.225 89.59 206.325C89.6 206.325 89.6 206.325 89.61 206.335C89.62 206.335 89.62 206.345 89.63 206.345L139.87 234.775V285.065L43.67 229.705V60.135ZM244.75 229.705L148.58 285.075V234.775L219.8 194.115L244.75 179.875V229.705ZM297.2 139.625L253.49 164.795V114.995L278.85 100.395L297.21 89.825V139.625H297.2Z" />
</svg>
</template>
@@ -0,0 +1,20 @@
<script setup>
defineProps({
category: { type: String, required: true },
});
</script>
<template>
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full"
:class="category === 'sensor' ? 'bg-sky-50 text-sky-600' : 'bg-amber-50 text-amber-600'"
>
<svg v-if="category === 'sensor'" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v10.5a3.5 3.5 0 1 0 3 0" />
<path stroke-linecap="round" stroke-linejoin="round" d="M9.5 6h5" />
</svg>
<svg v-else class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M13 2 3 13.5h6.5L10.5 22 21 9.5h-6.5L13 2Z" />
</svg>
</div>
</template>
@@ -0,0 +1,29 @@
<script setup>
import { Link } from '@inertiajs/vue3';
defineProps({
href: { type: String, required: true },
active: { type: Boolean, default: false },
mobile: { type: Boolean, default: false },
});
</script>
<template>
<Link
:href="href"
:class="[
mobile
? 'block w-full ps-3 pe-4 py-2 border-l-4 text-start text-base font-medium transition duration-150 ease-in-out focus:outline-none'
: 'inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium leading-5 transition duration-150 ease-in-out focus:outline-none',
active
? mobile
? 'border-indigo-400 text-indigo-700 bg-indigo-50 focus:text-indigo-800 focus:bg-indigo-100 focus:border-indigo-700'
: 'border-indigo-400 text-gray-900 focus:border-indigo-700'
: mobile
? 'border-transparent text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:text-gray-700 focus:border-gray-300',
]"
>
<slot />
</Link>
</template>
@@ -0,0 +1,15 @@
<script setup>
defineProps({
status: { type: String, required: true },
});
const classes = {
online: 'bg-green-100 text-green-800',
offline: 'bg-gray-100 text-gray-600',
unknown: 'bg-yellow-100 text-yellow-800',
};
</script>
<template>
<span class="inline-block px-2 py-0.5 rounded text-xs" :class="classes[status] ?? classes.unknown">{{ status }}</span>
</template>
@@ -0,0 +1,31 @@
<script setup>
defineProps({
status: { type: String, required: true },
});
const dotColor = {
online: 'bg-green-500',
offline: 'bg-gray-300',
unknown: 'bg-yellow-400',
};
const label = {
online: 'Онлайн',
offline: 'Офлайн',
unknown: 'Неизвестно',
};
</script>
<template>
<span class="inline-flex items-center gap-1.5">
<span class="relative flex h-2.5 w-2.5">
<span
v-if="status === 'online'"
class="absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"
:class="dotColor[status]"
/>
<span class="relative inline-flex h-2.5 w-2.5 rounded-full" :class="dotColor[status] ?? dotColor.unknown" />
</span>
<span class="text-xs font-medium text-gray-500">{{ label[status] ?? status }}</span>
</span>
</template>
@@ -0,0 +1,143 @@
<script setup>
import { ref, computed } from 'vue';
import { Link, router, usePage } from '@inertiajs/vue3';
import ApplicationLogo from '@/Components/ApplicationLogo.vue';
import NavLink from '@/Components/NavLink.vue';
import { routes } from '@/routes';
const page = usePage();
const user = computed(() => page.props.auth.user);
const flash = computed(() => page.props.flash);
const currentPath = computed(() => new URL(page.url, window.location.origin).pathname);
const isActive = (prefix) => currentPath.value === prefix || currentPath.value.startsWith(`${prefix}/`);
const showingMobileMenu = ref(false);
const showingUserDropdown = ref(false);
function logout() {
router.post(routes.logout);
}
const navLinks = [
{ href: routes.dashboard, label: 'Dashboard', prefix: routes.dashboard },
{ href: routes.zones.index, label: 'Зоны', prefix: routes.zones.index },
{ href: routes.devices.index, label: 'Устройства', prefix: routes.devices.index },
{ href: routes.automationRules.index, label: 'Правила', prefix: routes.automationRules.index },
];
</script>
<template>
<div class="min-h-screen bg-gray-100">
<nav class="bg-white border-b border-gray-100">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="shrink-0 flex items-center">
<Link :href="routes.dashboard">
<ApplicationLogo class="block h-9 w-auto fill-current text-gray-800" />
</Link>
</div>
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<NavLink
v-for="link in navLinks"
:key="link.href"
:href="link.href"
:active="isActive(link.prefix)"
>
{{ link.label }}
</NavLink>
</div>
</div>
<div class="hidden sm:flex sm:items-center sm:ms-6">
<div class="relative">
<button
type="button"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150"
@click="showingUserDropdown = !showingUserDropdown"
>
<div>{{ user?.name }}</div>
<svg class="ms-1 fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
<div
v-show="showingUserDropdown"
class="absolute end-0 z-50 mt-2 w-48 rounded-md shadow-lg"
@click="showingUserDropdown = false"
>
<div class="rounded-md ring-1 ring-black ring-opacity-5 py-1 bg-white">
<Link :href="routes.profile.edit" class="block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 hover:bg-gray-100">
Профиль
</Link>
<button type="button" class="block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 hover:bg-gray-100" @click="logout">
Выйти
</button>
</div>
</div>
</div>
</div>
<div class="-me-2 flex items-center sm:hidden">
<button
type="button"
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none transition duration-150 ease-in-out"
@click="showingMobileMenu = !showingMobileMenu"
>
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path v-if="!showingMobileMenu" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<div v-show="showingMobileMenu" class="sm:hidden">
<div class="pt-2 pb-3 space-y-1">
<NavLink
v-for="link in navLinks"
:key="link.href"
:href="link.href"
:active="isActive(link.prefix)"
mobile
>
{{ link.label }}
</NavLink>
</div>
<div class="pt-4 pb-1 border-t border-gray-200">
<div class="px-4">
<div class="font-medium text-base text-gray-800">{{ user?.name }}</div>
<div class="font-medium text-sm text-gray-500">{{ user?.email }}</div>
</div>
<div class="mt-3 space-y-1">
<NavLink :href="routes.profile.edit" mobile>Профиль</NavLink>
<button type="button" class="block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" @click="logout">
Выйти
</button>
</div>
</div>
</div>
</nav>
<header v-if="$slots.header" class="bg-white shadow">
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<slot name="header" />
</div>
</header>
<main>
<div v-if="flash.status || flash.error" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-4">
<div v-if="flash.status" class="rounded-md bg-green-50 p-4 text-sm text-green-700">{{ flash.status }}</div>
<div v-if="flash.error" class="rounded-md bg-red-50 p-4 text-sm text-red-700">{{ flash.error }}</div>
</div>
<slot />
</main>
</div>
</template>
@@ -0,0 +1,34 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import Form from '@/Pages/AutomationRules/Form.vue';
defineProps({
zones: { type: Array, required: true },
devices: { type: Array, required: true },
actionTypeOptions: { type: Array, required: true },
sensorTypeOptions: { type: Array, required: true },
conditionOperatorOptions: { type: Array, required: true },
});
</script>
<template>
<AppLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Новое правило</h2>
</template>
<div class="py-12">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<Form
:zones="zones"
:devices="devices"
:action-type-options="actionTypeOptions"
:sensor-type-options="sensorTypeOptions"
:condition-operator-options="conditionOperatorOptions"
/>
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,36 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import Form from '@/Pages/AutomationRules/Form.vue';
defineProps({
rule: { type: Object, required: true },
zones: { type: Array, required: true },
devices: { type: Array, required: true },
actionTypeOptions: { type: Array, required: true },
sensorTypeOptions: { type: Array, required: true },
conditionOperatorOptions: { type: Array, required: true },
});
</script>
<template>
<AppLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Изменить правило</h2>
</template>
<div class="py-12">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<Form
:rule="rule"
:zones="zones"
:devices="devices"
:action-type-options="actionTypeOptions"
:sensor-type-options="sensorTypeOptions"
:condition-operator-options="conditionOperatorOptions"
/>
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,176 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import { routes } from '@/routes';
const props = defineProps({
rule: { type: Object, default: null },
zones: { type: Array, required: true },
devices: { type: Array, required: true },
actionTypeOptions: { type: Array, required: true },
sensorTypeOptions: { type: Array, required: true },
conditionOperatorOptions: { type: Array, required: true },
});
const isEdit = !!props.rule;
const form = useForm({
zone_id: props.rule?.zone_id ?? '',
condition_source_device_id: props.rule?.condition_source_device_id ?? '',
condition_sensor_type: props.rule?.condition_sensor_type ?? '',
condition_operator: props.rule?.condition_operator ?? '',
condition_value: props.rule?.condition_value ?? '',
target_device_id: props.rule?.target_device_id ?? '',
action_type: props.rule?.action_type ?? '',
level: props.rule?.level ?? '',
is_active: props.rule?.is_active ?? true,
});
function submit() {
if (isEdit) {
form.put(routes.automationRules.update(props.rule.id));
} else {
form.post(routes.automationRules.store);
}
}
</script>
<template>
<form @submit.prevent="submit">
<div>
<label for="zone_id" class="block font-medium text-sm text-gray-700">Зона</label>
<select
id="zone_id"
v-model="form.zone_id"
required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<option value=""> выбрать </option>
<option v-for="zone in zones" :key="zone.id" :value="zone.id">{{ zone.name }}</option>
</select>
<div v-if="form.errors.zone_id" class="mt-2 text-sm text-red-600">{{ form.errors.zone_id }}</div>
</div>
<div class="mt-4">
<label for="condition_source_device_id" class="block font-medium text-sm text-gray-700">Устройство-источник показания</label>
<select
id="condition_source_device_id"
v-model="form.condition_source_device_id"
required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<option value=""> выбрать </option>
<option v-for="device in devices" :key="device.id" :value="device.id">
{{ device.zone.name }} {{ device.name }} ({{ device.device_type.code }})
</option>
</select>
<div v-if="form.errors.condition_source_device_id" class="mt-2 text-sm text-red-600">{{ form.errors.condition_source_device_id }}</div>
</div>
<div class="mt-4 grid grid-cols-3 gap-4">
<div>
<label for="condition_sensor_type" class="block font-medium text-sm text-gray-700">Тип показания</label>
<input
id="condition_sensor_type"
v-model="form.condition_sensor_type"
list="sensor-types"
type="text"
required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<datalist id="sensor-types">
<option v-for="option in sensorTypeOptions" :key="option" :value="option" />
</datalist>
<div v-if="form.errors.condition_sensor_type" class="mt-2 text-sm text-red-600">{{ form.errors.condition_sensor_type }}</div>
</div>
<div>
<label for="condition_operator" class="block font-medium text-sm text-gray-700">Оператор</label>
<select
id="condition_operator"
v-model="form.condition_operator"
required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<option value=""> выбрать </option>
<option v-for="op in conditionOperatorOptions" :key="op" :value="op">{{ op }}</option>
</select>
<div v-if="form.errors.condition_operator" class="mt-2 text-sm text-red-600">{{ form.errors.condition_operator }}</div>
</div>
<div>
<label for="condition_value" class="block font-medium text-sm text-gray-700">Порог</label>
<input
id="condition_value"
v-model="form.condition_value"
type="number"
step="any"
required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<div v-if="form.errors.condition_value" class="mt-2 text-sm text-red-600">{{ form.errors.condition_value }}</div>
</div>
</div>
<div class="mt-4">
<label for="target_device_id" class="block font-medium text-sm text-gray-700">Устройство-исполнитель</label>
<select
id="target_device_id"
v-model="form.target_device_id"
required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<option value=""> выбрать </option>
<option v-for="device in devices" :key="device.id" :value="device.id">
{{ device.zone.name }} {{ device.name }} ({{ device.device_type.code }})
</option>
</select>
<div v-if="form.errors.target_device_id" class="mt-2 text-sm text-red-600">{{ form.errors.target_device_id }}</div>
</div>
<div class="mt-4 grid grid-cols-2 gap-4">
<div>
<label for="action_type" class="block font-medium text-sm text-gray-700">Действие</label>
<select
id="action_type"
v-model="form.action_type"
required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<option value=""> выбрать </option>
<option v-for="option in actionTypeOptions" :key="option" :value="option">{{ option }}</option>
</select>
<div v-if="form.errors.action_type" class="mt-2 text-sm text-red-600">{{ form.errors.action_type }}</div>
</div>
<div v-show="form.action_type === 'set_level'">
<label for="level" class="block font-medium text-sm text-gray-700">Уровень</label>
<input
id="level"
v-model="form.level"
type="number"
step="any"
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<div v-if="form.errors.level" class="mt-2 text-sm text-red-600">{{ form.errors.level }}</div>
</div>
</div>
<div class="mt-4">
<label class="inline-flex items-center">
<input v-model="form.is_active" type="checkbox" class="rounded border-gray-300 text-indigo-600 shadow-sm">
<span class="ms-2 text-sm text-gray-600">Правило активно</span>
</label>
</div>
<div class="mt-4 flex items-center gap-4">
<button
type="submit"
:disabled="form.processing"
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 disabled:opacity-50"
>
Сохранить
</button>
<a :href="routes.automationRules.index" class="text-sm text-gray-600 hover:underline">Отмена</a>
</div>
</form>
</template>
@@ -0,0 +1,78 @@
<script setup>
import { computed } from 'vue';
import { Link, router, usePage } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
import { routes } from '@/routes';
defineProps({
rules: { type: Array, required: true },
});
const isOwner = computed(() => usePage().props.auth.user?.role === 'owner');
function destroy(rule) {
if (confirm('Удалить правило?')) {
router.delete(routes.automationRules.destroy(rule.id));
}
}
</script>
<template>
<AppLayout>
<template #header>
<div class="flex justify-between items-center">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Правила автоматизации</h2>
<Link
v-if="isOwner"
:href="routes.automationRules.create"
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700"
>
Добавить правило
</Link>
</div>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2">Зона</th>
<th class="py-2">Условие</th>
<th class="py-2">Действие</th>
<th class="py-2">Активно</th>
<th class="py-2" />
</tr>
</thead>
<tbody>
<tr v-if="rules.length === 0">
<td colspan="5" class="py-4 text-gray-500">Правил пока нет.</td>
</tr>
<tr v-for="rule in rules" :key="rule.id" class="border-b">
<td class="py-2">{{ rule.zone.name }}</td>
<td class="py-2">
{{ rule.condition_source_device.name }}:
{{ rule.condition_sensor_type }}
{{ rule.condition_operator }}
{{ rule.condition_value }}
</td>
<td class="py-2">
{{ rule.target_device.name }}: {{ rule.action_type }}
<template v-if="Object.keys(rule.action_params ?? {}).length">
({{ JSON.stringify(rule.action_params) }})
</template>
</td>
<td class="py-2">{{ rule.is_active ? 'да' : 'нет' }}</td>
<td class="py-2 text-right space-x-2">
<Link v-if="isOwner" :href="routes.automationRules.edit(rule.id)" class="text-indigo-600 hover:underline">Изменить</Link>
<button v-if="isOwner" type="button" class="text-red-600 hover:underline" @click="destroy(rule)">Удалить</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,41 @@
<script setup>
import { Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
import { routes } from '@/routes';
defineProps({
zonesCount: Number,
devicesCount: Number,
onlineDevicesCount: Number,
activeRulesCount: Number,
});
</script>
<template>
<AppLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Dashboard</h2>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 grid grid-cols-1 sm:grid-cols-4 gap-6">
<Link :href="routes.zones.index" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
<div class="text-sm text-gray-500">Зоны</div>
<div class="text-3xl font-semibold text-gray-900">{{ zonesCount }}</div>
</Link>
<Link :href="routes.devices.index" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
<div class="text-sm text-gray-500">Устройства</div>
<div class="text-3xl font-semibold text-gray-900">{{ devicesCount }}</div>
</Link>
<Link :href="routes.devices.index" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
<div class="text-sm text-gray-500">Онлайн</div>
<div class="text-3xl font-semibold text-green-600">{{ onlineDevicesCount }}</div>
</Link>
<Link :href="routes.automationRules.index" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
<div class="text-sm text-gray-500">Активных правил</div>
<div class="text-3xl font-semibold text-gray-900">{{ activeRulesCount }}</div>
</Link>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,25 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import Form from '@/Pages/Devices/Form.vue';
defineProps({
zones: { type: Array, required: true },
deviceTypes: { type: Array, required: true },
});
</script>
<template>
<AppLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Новое устройство</h2>
</template>
<div class="py-12">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<Form :zones="zones" :device-types="deviceTypes" />
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,26 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import Form from '@/Pages/Devices/Form.vue';
defineProps({
device: { type: Object, required: true },
zones: { type: Array, required: true },
deviceTypes: { type: Array, required: true },
});
</script>
<template>
<AppLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Изменить устройство</h2>
</template>
<div class="py-12">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<Form :device="device" :zones="zones" :device-types="deviceTypes" />
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,108 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import { routes } from '@/routes';
const props = defineProps({
device: { type: Object, default: null },
zones: { type: Array, required: true },
deviceTypes: { type: Array, required: true },
});
const isEdit = !!props.device;
const form = useForm({
name: props.device?.name ?? '',
zone_id: props.device?.zone_id ?? '',
device_type_id: props.device?.device_type_id ?? '',
external_id: props.device?.external_id ?? '',
protocol: props.device?.protocol ?? 'mqtt',
});
function submit() {
if (isEdit) {
form.put(routes.devices.update(props.device.id));
} else {
form.post(routes.devices.store);
}
}
</script>
<template>
<form @submit.prevent="submit">
<div>
<label for="name" class="block font-medium text-sm text-gray-700">Название</label>
<input
id="name"
v-model="form.name"
type="text"
required
autofocus
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<div v-if="form.errors.name" class="mt-2 text-sm text-red-600">{{ form.errors.name }}</div>
</div>
<div class="mt-4">
<label for="zone_id" class="block font-medium text-sm text-gray-700">Зона</label>
<select
id="zone_id"
v-model="form.zone_id"
required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<option value=""> выбрать </option>
<option v-for="zone in zones" :key="zone.id" :value="zone.id">{{ zone.name }}</option>
</select>
<div v-if="form.errors.zone_id" class="mt-2 text-sm text-red-600">{{ form.errors.zone_id }}</div>
</div>
<div class="mt-4">
<label for="device_type_id" class="block font-medium text-sm text-gray-700">Тип устройства</label>
<select
id="device_type_id"
v-model="form.device_type_id"
required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<option value=""> выбрать </option>
<option v-for="type in deviceTypes" :key="type.id" :value="type.id">{{ type.code }} ({{ type.category }})</option>
</select>
<div v-if="form.errors.device_type_id" class="mt-2 text-sm text-red-600">{{ form.errors.device_type_id }}</div>
</div>
<div class="mt-4">
<label for="external_id" class="block font-medium text-sm text-gray-700">External ID (идентификатор физического устройства)</label>
<input
id="external_id"
v-model="form.external_id"
type="text"
required
class="mt-1 block w-full font-mono border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<div v-if="form.errors.external_id" class="mt-2 text-sm text-red-600">{{ form.errors.external_id }}</div>
</div>
<div class="mt-4">
<label for="protocol" class="block font-medium text-sm text-gray-700">Протокол</label>
<input
id="protocol"
v-model="form.protocol"
type="text"
required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<div v-if="form.errors.protocol" class="mt-2 text-sm text-red-600">{{ form.errors.protocol }}</div>
</div>
<div class="mt-4 flex items-center gap-4">
<button
type="submit"
:disabled="form.processing"
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 disabled:opacity-50"
>
Сохранить
</button>
<a :href="routes.devices.index" class="text-sm text-gray-600 hover:underline">Отмена</a>
</div>
</form>
</template>
@@ -0,0 +1,73 @@
<script setup>
import { computed } from 'vue';
import { Link, router, usePage } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
import StatusBadge from '@/Components/StatusBadge.vue';
import { routes } from '@/routes';
defineProps({
devices: { type: Array, required: true },
});
const isOwner = computed(() => usePage().props.auth.user?.role === 'owner');
function destroy(device) {
if (confirm('Удалить устройство?')) {
router.delete(routes.devices.destroy(device.id));
}
}
</script>
<template>
<AppLayout>
<template #header>
<div class="flex justify-between items-center">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Устройства</h2>
<Link
v-if="isOwner"
:href="routes.devices.create"
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700"
>
Добавить устройство
</Link>
</div>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2">Название</th>
<th class="py-2">Зона</th>
<th class="py-2">Тип</th>
<th class="py-2">Статус</th>
<th class="py-2">External ID</th>
<th class="py-2" />
</tr>
</thead>
<tbody>
<tr v-if="devices.length === 0">
<td colspan="6" class="py-4 text-gray-500">Устройств пока нет.</td>
</tr>
<tr v-for="device in devices" :key="device.id" class="border-b">
<td class="py-2">
<Link :href="routes.devices.show(device.id)" class="text-indigo-600 hover:underline">{{ device.name }}</Link>
</td>
<td class="py-2">{{ device.zone.name }}</td>
<td class="py-2">{{ device.device_type.code }}</td>
<td class="py-2"><StatusBadge :status="device.status" /></td>
<td class="py-2 font-mono text-xs">{{ device.external_id }}</td>
<td class="py-2 text-right space-x-2">
<Link v-if="isOwner" :href="routes.devices.edit(device.id)" class="text-indigo-600 hover:underline">Изменить</Link>
<button v-if="isOwner" type="button" class="text-red-600 hover:underline" @click="destroy(device)">Удалить</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,132 @@
<script setup>
import { computed } from 'vue';
import { useForm, usePage } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
import StatusBadge from '@/Components/StatusBadge.vue';
import { routes } from '@/routes';
const props = defineProps({
device: { type: Object, required: true },
shadow: { type: Object, required: true },
telemetry: { type: Array, required: true },
});
const isOwner = computed(() => usePage().props.auth.user?.role === 'owner');
const capabilities = computed(() => props.device.device_type.capabilities ?? []);
const hasControls = computed(() => capabilities.value.some((c) => ['turn_on', 'turn_off', 'set_level'].includes(c)));
const lastSeenLabel = computed(() => (
props.shadow.last_seen ? new Date(props.shadow.last_seen).toLocaleString('ru-RU') : 'нет данных'
));
const levelForm = useForm({ level: '' });
function turnOn() {
useForm({}).post(routes.devices.turnOn(props.device.id));
}
function turnOff() {
useForm({}).post(routes.devices.turnOff(props.device.id));
}
function setLevel() {
levelForm.post(routes.devices.setLevel(props.device.id));
}
</script>
<template>
<AppLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ device.name }}</h2>
</template>
<div class="py-12">
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8 space-y-6">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<h3 class="font-semibold mb-4">Живое состояние (Redis device shadow)</h3>
<dl class="grid grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-gray-500">Статус</dt>
<dd class="mt-1"><StatusBadge :status="device.status" /></dd>
</div>
<div>
<dt class="text-gray-500">Последняя активность</dt>
<dd>{{ lastSeenLabel }}</dd>
</div>
<div>
<dt class="text-gray-500">Желаемое состояние (desired)</dt>
<dd class="font-mono text-xs">{{ JSON.stringify(shadow.desired_state) }}</dd>
</div>
<div>
<dt class="text-gray-500">Подтверждённое состояние (reported)</dt>
<dd class="font-mono text-xs">{{ JSON.stringify(shadow.reported_state) }}</dd>
</div>
</dl>
</div>
<div v-if="isOwner && hasControls" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<h3 class="font-semibold mb-4">Ручное управление</h3>
<div class="flex items-center gap-4 flex-wrap">
<button
v-if="capabilities.includes('turn_on')"
type="button"
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700"
@click="turnOn"
>
Включить
</button>
<button
v-if="capabilities.includes('turn_off')"
type="button"
class="inline-flex items-center px-4 py-2 bg-white border border-gray-300 rounded-md font-semibold text-xs text-gray-700 uppercase tracking-widest hover:bg-gray-50"
@click="turnOff"
>
Выключить
</button>
<form v-if="capabilities.includes('set_level')" class="flex items-center gap-2" @submit.prevent="setLevel">
<input
v-model="levelForm.level"
type="number"
step="any"
required
placeholder="уровень"
class="border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm text-sm"
>
<button
type="submit"
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700"
>
Установить уровень
</button>
</form>
</div>
</div>
<div v-if="device.device_type.category === 'sensor'" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<h3 class="font-semibold mb-4">Последние показания (ClickHouse)</h3>
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2">Тип</th>
<th class="py-2">Значение</th>
<th class="py-2">Время</th>
</tr>
</thead>
<tbody>
<tr v-if="telemetry.length === 0">
<td colspan="3" class="py-4 text-gray-500">Показаний пока нет.</td>
</tr>
<tr v-for="(row, i) in telemetry" :key="i" class="border-b">
<td class="py-2">{{ row.sensor_type }}</td>
<td class="py-2">{{ row.value }}</td>
<td class="py-2">{{ row.recorded_at }}</td>
</tr>
</tbody>
</table>
</div>
<a :href="routes.devices.index" class="text-sm text-gray-600 hover:underline"> Назад к устройствам</a>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,40 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import UpdateProfileInformationForm from '@/Pages/Profile/Partials/UpdateProfileInformationForm.vue';
import UpdatePasswordForm from '@/Pages/Profile/Partials/UpdatePasswordForm.vue';
import DeleteUserForm from '@/Pages/Profile/Partials/DeleteUserForm.vue';
defineProps({
user: { type: Object, required: true },
});
</script>
<template>
<AppLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Профиль</h2>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
<div class="max-w-xl">
<UpdateProfileInformationForm :user="user" />
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
<div class="max-w-xl">
<UpdatePasswordForm />
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
<div class="max-w-xl">
<DeleteUserForm />
</div>
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,88 @@
<script setup>
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import { routes } from '@/routes';
const confirmingDeletion = ref(false);
const form = useForm({ password: '' });
function confirmDeletion() {
confirmingDeletion.value = true;
}
function closeModal() {
confirmingDeletion.value = false;
form.reset();
form.clearErrors();
}
function submit() {
form.delete(routes.profile.destroy, {
preserveScroll: true,
errorBag: 'userDeletion',
onSuccess: () => closeModal(),
onError: () => {},
onFinish: () => form.reset('password'),
});
}
</script>
<template>
<section class="space-y-6">
<header>
<h2 class="text-lg font-medium text-gray-900">Удаление аккаунта</h2>
<p class="mt-1 text-sm text-gray-600">
После удаления аккаунта все связанные данные будут безвозвратно удалены.
</p>
</header>
<button
type="button"
class="inline-flex items-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-500"
@click="confirmDeletion"
>
Удалить аккаунт
</button>
<div v-if="confirmingDeletion" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full p-6">
<form @submit.prevent="submit">
<h2 class="text-lg font-medium text-gray-900">Точно удалить аккаунт?</h2>
<p class="mt-1 text-sm text-gray-600">
Это необратимо. Введите пароль, чтобы подтвердить удаление.
</p>
<div class="mt-6">
<label for="delete-password" class="sr-only">Пароль</label>
<input
id="delete-password"
v-model="form.password"
type="password"
placeholder="Пароль"
class="mt-1 block w-3/4 border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<div v-if="form.errors.password" class="mt-2 text-sm text-red-600">{{ form.errors.password }}</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
class="inline-flex items-center px-4 py-2 bg-white border border-gray-300 rounded-md font-semibold text-xs text-gray-700 uppercase tracking-widest hover:bg-gray-50"
@click="closeModal"
>
Отмена
</button>
<button
type="submit"
:disabled="form.processing"
class="inline-flex items-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-500 disabled:opacity-50"
>
Удалить аккаунт
</button>
</div>
</form>
</div>
</div>
</section>
</template>
@@ -0,0 +1,82 @@
<script setup>
import { computed } from 'vue';
import { useForm, usePage } from '@inertiajs/vue3';
import { routes } from '@/routes';
const form = useForm({
current_password: '',
password: '',
password_confirmation: '',
});
const flashStatus = computed(() => usePage().props.flash.status);
function submit() {
form.put(routes.password.update, {
preserveScroll: true,
onSuccess: () => form.reset(),
onError: () => {
if (form.errors.password) form.reset('password', 'password_confirmation');
if (form.errors.current_password) form.reset('current_password');
},
});
}
</script>
<template>
<section>
<header>
<h2 class="text-lg font-medium text-gray-900">Смена пароля</h2>
<p class="mt-1 text-sm text-gray-600">Длинный случайный пароль надёжнее короткого запоминающегося.</p>
</header>
<form class="mt-6 space-y-6" @submit.prevent="submit">
<div>
<label for="current_password" class="block font-medium text-sm text-gray-700">Текущий пароль</label>
<input
id="current_password"
v-model="form.current_password"
type="password"
autocomplete="current-password"
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<div v-if="form.errors.current_password" class="mt-2 text-sm text-red-600">{{ form.errors.current_password }}</div>
</div>
<div>
<label for="password" class="block font-medium text-sm text-gray-700">Новый пароль</label>
<input
id="password"
v-model="form.password"
type="password"
autocomplete="new-password"
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<div v-if="form.errors.password" class="mt-2 text-sm text-red-600">{{ form.errors.password }}</div>
</div>
<div>
<label for="password_confirmation" class="block font-medium text-sm text-gray-700">Подтверждение пароля</label>
<input
id="password_confirmation"
v-model="form.password_confirmation"
type="password"
autocomplete="new-password"
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<div v-if="form.errors.password_confirmation" class="mt-2 text-sm text-red-600">{{ form.errors.password_confirmation }}</div>
</div>
<div class="flex items-center gap-4">
<button
type="submit"
:disabled="form.processing"
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 disabled:opacity-50"
>
Сохранить
</button>
<p v-if="flashStatus === 'password-updated'" class="text-sm text-gray-600">Сохранено.</p>
</div>
</form>
</section>
</template>
@@ -0,0 +1,69 @@
<script setup>
import { computed } from 'vue';
import { useForm, usePage } from '@inertiajs/vue3';
import { routes } from '@/routes';
const props = defineProps({
user: { type: Object, required: true },
});
const form = useForm({
name: props.user.name,
email: props.user.email,
});
const flashStatus = computed(() => usePage().props.flash.status);
function submit() {
form.patch(routes.profile.update);
}
</script>
<template>
<section>
<header>
<h2 class="text-lg font-medium text-gray-900">Данные профиля</h2>
<p class="mt-1 text-sm text-gray-600">Имя и email аккаунта.</p>
</header>
<form class="mt-6 space-y-6" @submit.prevent="submit">
<div>
<label for="name" class="block font-medium text-sm text-gray-700">Имя</label>
<input
id="name"
v-model="form.name"
type="text"
required
autofocus
autocomplete="name"
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<div v-if="form.errors.name" class="mt-2 text-sm text-red-600">{{ form.errors.name }}</div>
</div>
<div>
<label for="email" class="block font-medium text-sm text-gray-700">Email</label>
<input
id="email"
v-model="form.email"
type="email"
required
autocomplete="username"
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<div v-if="form.errors.email" class="mt-2 text-sm text-red-600">{{ form.errors.email }}</div>
</div>
<div class="flex items-center gap-4">
<button
type="submit"
:disabled="form.processing"
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 disabled:opacity-50"
>
Сохранить
</button>
<p v-if="flashStatus === 'profile-updated'" class="text-sm text-gray-600">Сохранено.</p>
</div>
</form>
</section>
</template>
@@ -0,0 +1,20 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import Form from '@/Pages/Zones/Form.vue';
</script>
<template>
<AppLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Новая зона</h2>
</template>
<div class="py-12">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<Form />
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,24 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import Form from '@/Pages/Zones/Form.vue';
defineProps({
zone: { type: Object, required: true },
});
</script>
<template>
<AppLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Изменить зону</h2>
</template>
<div class="py-12">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<Form :zone="zone" />
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,62 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import { routes } from '@/routes';
const props = defineProps({
zone: { type: Object, default: null },
});
const isEdit = !!props.zone;
const form = useForm({
name: props.zone?.name ?? '',
description: props.zone?.description ?? '',
});
function submit() {
if (isEdit) {
form.put(routes.zones.update(props.zone.id));
} else {
form.post(routes.zones.store);
}
}
</script>
<template>
<form @submit.prevent="submit">
<div>
<label for="name" class="block font-medium text-sm text-gray-700">Название</label>
<input
id="name"
v-model="form.name"
type="text"
required
autofocus
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<div v-if="form.errors.name" class="mt-2 text-sm text-red-600">{{ form.errors.name }}</div>
</div>
<div class="mt-4">
<label for="description" class="block font-medium text-sm text-gray-700">Описание</label>
<textarea
id="description"
v-model="form.description"
rows="3"
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
/>
<div v-if="form.errors.description" class="mt-2 text-sm text-red-600">{{ form.errors.description }}</div>
</div>
<div class="mt-4 flex items-center gap-4">
<button
type="submit"
:disabled="form.processing"
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 disabled:opacity-50"
>
Сохранить
</button>
<a :href="routes.zones.index" class="text-sm text-gray-600 hover:underline">Отмена</a>
</div>
</form>
</template>
@@ -0,0 +1,67 @@
<script setup>
import { computed } from 'vue';
import { Link, router, usePage } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
import { routes } from '@/routes';
defineProps({
zones: { type: Array, required: true },
});
const isOwner = computed(() => usePage().props.auth.user?.role === 'owner');
function destroy(zone) {
if (confirm('Удалить зону?')) {
router.delete(routes.zones.destroy(zone.id));
}
}
</script>
<template>
<AppLayout>
<template #header>
<div class="flex justify-between items-center">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Зоны</h2>
<Link
v-if="isOwner"
:href="routes.zones.create"
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700"
>
Добавить зону
</Link>
</div>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div v-if="zones.length === 0" class="bg-white rounded-xl border border-dashed border-gray-300 p-10 text-center text-sm text-gray-500">
Зон пока нет.
</div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="zone in zones"
:key="zone.id"
class="group relative flex flex-col gap-3 rounded-xl border border-gray-200 bg-white p-5 transition hover:border-indigo-200 hover:shadow-md"
>
<Link :href="routes.zones.show(zone.id)" class="absolute inset-0" :aria-label="zone.name" />
<div class="flex items-start justify-between">
<h3 class="font-medium text-gray-900 group-hover:text-indigo-600">{{ zone.name }}</h3>
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600">
{{ zone.devices_count }} {{ zone.devices_count === 1 ? 'устройство' : 'устройств' }}
</span>
</div>
<p class="text-sm text-gray-500 min-h-5">{{ zone.description }}</p>
<div v-if="isOwner" class="relative z-10 mt-2 flex items-center gap-3 border-t border-gray-100 pt-3 text-sm">
<Link :href="routes.zones.edit(zone.id)" class="text-indigo-600 hover:underline">Изменить</Link>
<button type="button" class="text-red-600 hover:underline" @click="destroy(zone)">Удалить</button>
</div>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,76 @@
<script setup>
import { computed } from 'vue';
import { Link, usePage } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
import DeviceTypeIcon from '@/Components/DeviceTypeIcon.vue';
import StatusDot from '@/Components/StatusDot.vue';
import { routes } from '@/routes';
const props = defineProps({
zone: { type: Object, required: true },
devices: { type: Array, required: true },
});
const isOwner = computed(() => usePage().props.auth.user?.role === 'owner');
const onlineCount = computed(() => props.devices.filter((d) => d.status === 'online').length);
</script>
<template>
<AppLayout>
<template #header>
<div class="flex items-center justify-between">
<div>
<Link :href="routes.zones.index" class="text-sm text-gray-500 hover:text-gray-700"> Зоны</Link>
<h2 class="mt-1 font-semibold text-xl text-gray-800 leading-tight">{{ zone.name }}</h2>
</div>
<Link
v-if="isOwner"
:href="routes.zones.edit(zone.id)"
class="inline-flex items-center px-4 py-2 bg-white border border-gray-300 rounded-md font-semibold text-xs text-gray-700 uppercase tracking-widest hover:bg-gray-50"
>
Изменить зону
</Link>
</div>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<div v-if="zone.description" class="text-sm text-gray-600">{{ zone.description }}</div>
<div class="flex items-center gap-2 text-sm text-gray-500">
<span class="font-medium text-gray-700">{{ devices.length }}</span> устройств,
<span class="font-medium text-green-600">{{ onlineCount }}</span> онлайн
</div>
<div v-if="devices.length === 0" class="bg-white rounded-xl border border-dashed border-gray-300 p-10 text-center text-sm text-gray-500">
В этой зоне пока нет устройств.
</div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<Link
v-for="device in devices"
:key="device.id"
:href="routes.devices.show(device.id)"
class="group flex flex-col gap-4 rounded-xl border border-gray-200 bg-white p-5 transition hover:border-indigo-200 hover:shadow-md"
>
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-3">
<DeviceTypeIcon :category="device.device_type.category" />
<div>
<div class="font-medium text-gray-900 group-hover:text-indigo-600">{{ device.name }}</div>
<div class="text-xs text-gray-500">{{ device.device_type.code }}</div>
</div>
</div>
</div>
<div class="flex items-center justify-between border-t border-gray-100 pt-3">
<StatusDot :status="device.status" />
<span class="font-mono text-xs text-gray-400">{{ device.external_id }}</span>
</div>
</Link>
</div>
</div>
</div>
</AppLayout>
</template>
+15 -5
View File
@@ -1,7 +1,17 @@
import '../css/app.css';
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
createInertiaApp({
title: (title) => (title ? `${title} — Домашняя автоматизация` : 'Домашняя автоматизация'),
resolve: (name) => {
const pages = import.meta.glob('./Pages/**/*.vue', { eager: true });
import Alpine from 'alpinejs'; return pages[`./Pages/${name}.vue`];
},
window.Alpine = Alpine; setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
Alpine.start(); .use(plugin)
.mount(el);
},
});
+45
View File
@@ -0,0 +1,45 @@
// Single source of truth for the app's URLs on the JS side — mirrors
// routes/web.php. The route list here is small and stable enough that a
// full Ziggy (route-name-to-JS) integration would be more machinery than
// the app needs; if the route count grows a lot, revisit that.
export const routes = {
dashboard: '/dashboard',
zones: {
index: '/zones',
show: (id) => `/zones/${id}`,
create: '/zones/create',
store: '/zones',
edit: (id) => `/zones/${id}/edit`,
update: (id) => `/zones/${id}`,
destroy: (id) => `/zones/${id}`,
},
devices: {
index: '/devices',
show: (id) => `/devices/${id}`,
create: '/devices/create',
store: '/devices',
edit: (id) => `/devices/${id}/edit`,
update: (id) => `/devices/${id}`,
destroy: (id) => `/devices/${id}`,
turnOn: (id) => `/devices/${id}/turn-on`,
turnOff: (id) => `/devices/${id}/turn-off`,
setLevel: (id) => `/devices/${id}/set-level`,
},
automationRules: {
index: '/automation-rules',
create: '/automation-rules/create',
store: '/automation-rules',
edit: (id) => `/automation-rules/${id}/edit`,
update: (id) => `/automation-rules/${id}`,
destroy: (id) => `/automation-rules/${id}`,
},
profile: {
edit: '/profile',
update: '/profile',
destroy: '/profile',
},
password: {
update: '/password',
},
logout: '/logout',
};
@@ -7,30 +7,13 @@
<title>{{ config('app.name', 'Laravel') }}</title> <title>{{ config('app.name', 'Laravel') }}</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net"> <link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" /> <link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])
@inertiaHead
</head> </head>
<body class="font-sans antialiased"> <body class="font-sans antialiased">
<div class="min-h-screen bg-gray-100"> @inertia
@include('layouts.navigation')
<!-- Page Heading -->
@isset($header)
<header class="bg-white shadow">
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{{ $header }}
</div>
</header>
@endisset
<!-- Page Content -->
<main>
{{ $slot }}
</main>
</div>
</body> </body>
</html> </html>
@@ -1,116 +0,0 @@
@csrf
@isset($rule)
@method('PUT')
@endisset
<div x-data="{ actionType: '{{ old('action_type', $rule->action_type ?? '') }}' }">
<div>
<x-input-label for="zone_id" :value="__('Зона')" />
<select id="zone_id" name="zone_id" required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
<option value="">{{ __('— выбрать —') }}</option>
@foreach ($zones as $zone)
<option value="{{ $zone->id }}" @selected(old('zone_id', $rule->zone_id ?? '') == $zone->id)>{{ $zone->name }}</option>
@endforeach
</select>
<x-input-error :messages="$errors->get('zone_id')" class="mt-2" />
</div>
<div class="mt-4">
<x-input-label for="condition_source_device_id" :value="__('Устройство-источник показания')" />
<select id="condition_source_device_id" name="condition_source_device_id" required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
<option value="">{{ __('— выбрать —') }}</option>
@foreach ($devices as $device)
<option value="{{ $device->id }}" @selected(old('condition_source_device_id', $rule->condition_source_device_id ?? '') == $device->id)>
{{ $device->zone->name }} {{ $device->name }} ({{ $device->deviceType->code }})
</option>
@endforeach
</select>
<x-input-error :messages="$errors->get('condition_source_device_id')" class="mt-2" />
</div>
<div class="mt-4 grid grid-cols-3 gap-4">
<div>
<x-input-label for="condition_sensor_type" :value="__('Тип показания')" />
<input list="sensor-types" id="condition_sensor_type" name="condition_sensor_type" type="text"
value="{{ old('condition_sensor_type', $rule->condition_sensor_type ?? '') }}" required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm" />
<datalist id="sensor-types">
@foreach ($sensorTypeOptions as $option)
<option value="{{ $option }}"></option>
@endforeach
</datalist>
<x-input-error :messages="$errors->get('condition_sensor_type')" class="mt-2" />
</div>
<div>
<x-input-label for="condition_operator" :value="__('Оператор')" />
<select id="condition_operator" name="condition_operator" required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
@foreach (\App\Enums\ConditionOperator::cases() as $operator)
<option value="{{ $operator->value }}" @selected(old('condition_operator', $rule->condition_operator->value ?? '') == $operator->value)>
{{ $operator->value }}
</option>
@endforeach
</select>
<x-input-error :messages="$errors->get('condition_operator')" class="mt-2" />
</div>
<div>
<x-input-label for="condition_value" :value="__('Порог')" />
<x-text-input id="condition_value" name="condition_value" type="number" step="any" class="mt-1 block w-full"
:value="old('condition_value', $rule->condition_value ?? '')" required />
<x-input-error :messages="$errors->get('condition_value')" class="mt-2" />
</div>
</div>
<div class="mt-4">
<x-input-label for="target_device_id" :value="__('Устройство-исполнитель')" />
<select id="target_device_id" name="target_device_id" required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
<option value="">{{ __('— выбрать —') }}</option>
@foreach ($devices as $device)
<option value="{{ $device->id }}" @selected(old('target_device_id', $rule->target_device_id ?? '') == $device->id)>
{{ $device->zone->name }} {{ $device->name }} ({{ $device->deviceType->code }})
</option>
@endforeach
</select>
<x-input-error :messages="$errors->get('target_device_id')" class="mt-2" />
</div>
<div class="mt-4 grid grid-cols-2 gap-4">
<div>
<x-input-label for="action_type" :value="__('Действие')" />
<select id="action_type" name="action_type" required x-model="actionType"
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
<option value="">{{ __('— выбрать —') }}</option>
@foreach ($actionTypeOptions as $option)
<option value="{{ $option }}">{{ $option }}</option>
@endforeach
</select>
<x-input-error :messages="$errors->get('action_type')" class="mt-2" />
</div>
<div x-show="actionType === 'set_level'">
<x-input-label for="level" :value="__('Уровень')" />
<x-text-input id="level" name="level" type="number" step="any" class="mt-1 block w-full"
:value="old('level', $rule->action_params['level'] ?? '')" />
<x-input-error :messages="$errors->get('level')" class="mt-2" />
</div>
</div>
<div class="mt-4">
<label class="inline-flex items-center">
<input type="hidden" name="is_active" value="0">
<input type="checkbox" name="is_active" value="1" class="rounded border-gray-300 text-indigo-600 shadow-sm"
@checked(old('is_active', $rule->is_active ?? true))>
<span class="ms-2 text-sm text-gray-600">{{ __('Правило активно') }}</span>
</label>
</div>
<div class="mt-4 flex items-center gap-4">
<x-primary-button>{{ __('Сохранить') }}</x-primary-button>
<a href="{{ route('automation-rules.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('Отмена') }}</a>
</div>
</div>
@@ -1,15 +0,0 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ __('Новое правило автоматизации') }}</h2>
</x-slot>
<div class="py-12">
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<form method="POST" action="{{ route('automation-rules.store') }}">
@include('automation-rules._form')
</form>
</div>
</div>
</div>
</x-app-layout>
@@ -1,15 +0,0 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ __('Изменить правило автоматизации') }}</h2>
</x-slot>
<div class="py-12">
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<form method="POST" action="{{ route('automation-rules.update', $rule) }}">
@include('automation-rules._form')
</form>
</div>
</div>
</div>
</x-app-layout>
@@ -1,72 +0,0 @@
<x-app-layout>
<x-slot name="header">
<div class="flex justify-between items-center">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Правила автоматизации') }}
</h2>
@can('create', \App\Models\AutomationRule::class)
<x-primary-button onclick="window.location='{{ route('automation-rules.create') }}'">
{{ __('Добавить правило') }}
</x-primary-button>
@endcan
</div>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
@if (session('status'))
<div class="mb-4 text-sm text-green-600">{{ session('status') }}</div>
@endif
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2">{{ __('Зона') }}</th>
<th class="py-2">{{ __('Условие') }}</th>
<th class="py-2">{{ __('Действие') }}</th>
<th class="py-2">{{ __('Активно') }}</th>
<th class="py-2"></th>
</tr>
</thead>
<tbody>
@forelse ($rules as $rule)
<tr class="border-b">
<td class="py-2">{{ $rule->zone->name }}</td>
<td class="py-2">
{{ $rule->conditionSourceDevice->name }}:
{{ $rule->condition_sensor_type }}
{{ $rule->condition_operator->value }}
{{ $rule->condition_value }}
</td>
<td class="py-2">
{{ $rule->targetDevice->name }}: {{ $rule->action_type }}
@if (!empty($rule->action_params))
({{ json_encode($rule->action_params) }})
@endif
</td>
<td class="py-2">{{ $rule->is_active ? __('да') : __('нет') }}</td>
<td class="py-2 text-right space-x-2">
@can('update', $rule)
<a href="{{ route('automation-rules.edit', $rule) }}" class="text-indigo-600 hover:underline">{{ __('Изменить') }}</a>
@endcan
@can('delete', $rule)
<form method="POST" action="{{ route('automation-rules.destroy', $rule) }}" class="inline" onsubmit="return confirm('{{ __('Удалить правило?') }}')">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:underline">{{ __('Удалить') }}</button>
</form>
@endcan
</td>
</tr>
@empty
<tr>
<td colspan="5" class="py-4 text-gray-500">{{ __('Правил пока нет.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</x-app-layout>
@@ -1,3 +0,0 @@
<button {{ $attributes->merge(['type' => 'submit', 'class' => 'inline-flex items-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-500 active:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition ease-in-out duration-150']) }}>
{{ $slot }}
</button>
@@ -1 +0,0 @@
<a {{ $attributes->merge(['class' => 'block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 transition duration-150 ease-in-out']) }}>{{ $slot }}</a>
@@ -1,35 +0,0 @@
@props(['align' => 'right', 'width' => '48', 'contentClasses' => 'py-1 bg-white'])
@php
$alignmentClasses = match ($align) {
'left' => 'ltr:origin-top-left rtl:origin-top-right start-0',
'top' => 'origin-top',
default => 'ltr:origin-top-right rtl:origin-top-left end-0',
};
$width = match ($width) {
'48' => 'w-48',
default => $width,
};
@endphp
<div class="relative" x-data="{ open: false }" @click.outside="open = false" @close.stop="open = false">
<div @click="open = ! open">
{{ $trigger }}
</div>
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute z-50 mt-2 {{ $width }} rounded-md shadow-lg {{ $alignmentClasses }}"
style="display: none;"
@click="open = false">
<div class="rounded-md ring-1 ring-black ring-opacity-5 {{ $contentClasses }}">
{{ $content }}
</div>
</div>
</div>
@@ -1,78 +0,0 @@
@props([
'name',
'show' => false,
'maxWidth' => '2xl'
])
@php
$maxWidth = [
'sm' => 'sm:max-w-sm',
'md' => 'sm:max-w-md',
'lg' => 'sm:max-w-lg',
'xl' => 'sm:max-w-xl',
'2xl' => 'sm:max-w-2xl',
][$maxWidth];
@endphp
<div
x-data="{
show: @js($show),
focusables() {
// All focusable element types...
let selector = 'a, button, input:not([type=\'hidden\']), textarea, select, details, [tabindex]:not([tabindex=\'-1\'])'
return [...$el.querySelectorAll(selector)]
// All non-disabled elements...
.filter(el => ! el.hasAttribute('disabled'))
},
firstFocusable() { return this.focusables()[0] },
lastFocusable() { return this.focusables().slice(-1)[0] },
nextFocusable() { return this.focusables()[this.nextFocusableIndex()] || this.firstFocusable() },
prevFocusable() { return this.focusables()[this.prevFocusableIndex()] || this.lastFocusable() },
nextFocusableIndex() { return (this.focusables().indexOf(document.activeElement) + 1) % (this.focusables().length + 1) },
prevFocusableIndex() { return Math.max(0, this.focusables().indexOf(document.activeElement)) -1 },
}"
x-init="$watch('show', value => {
if (value) {
document.body.classList.add('overflow-y-hidden');
{{ $attributes->has('focusable') ? 'setTimeout(() => firstFocusable().focus(), 100)' : '' }}
} else {
document.body.classList.remove('overflow-y-hidden');
}
})"
x-on:open-modal.window="$event.detail == '{{ $name }}' ? show = true : null"
x-on:close-modal.window="$event.detail == '{{ $name }}' ? show = false : null"
x-on:close.stop="show = false"
x-on:keydown.escape.window="show = false"
x-on:keydown.tab.prevent="$event.shiftKey || nextFocusable().focus()"
x-on:keydown.shift.tab.prevent="prevFocusable().focus()"
x-show="show"
class="fixed inset-0 overflow-y-auto px-4 py-6 sm:px-0 z-50"
style="display: {{ $show ? 'block' : 'none' }};"
>
<div
x-show="show"
class="fixed inset-0 transform transition-all"
x-on:click="show = false"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
>
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<div
x-show="show"
class="mb-6 bg-white rounded-lg overflow-hidden shadow-xl transform transition-all sm:w-full {{ $maxWidth }} sm:mx-auto"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
{{ $slot }}
</div>
</div>
@@ -1,11 +0,0 @@
@props(['active'])
@php
$classes = ($active ?? false)
? 'inline-flex items-center px-1 pt-1 border-b-2 border-indigo-400 text-sm font-medium leading-5 text-gray-900 focus:outline-none focus:border-indigo-700 transition duration-150 ease-in-out'
: 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none focus:text-gray-700 focus:border-gray-300 transition duration-150 ease-in-out';
@endphp
<a {{ $attributes->merge(['class' => $classes]) }}>
{{ $slot }}
</a>
@@ -1,11 +0,0 @@
@props(['active'])
@php
$classes = ($active ?? false)
? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 text-start text-base font-medium text-indigo-700 bg-indigo-50 focus:outline-none focus:text-indigo-800 focus:bg-indigo-100 focus:border-indigo-700 transition duration-150 ease-in-out'
: 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300 transition duration-150 ease-in-out';
@endphp
<a {{ $attributes->merge(['class' => $classes]) }}>
{{ $slot }}
</a>
@@ -1,3 +0,0 @@
<button {{ $attributes->merge(['type' => 'button', 'class' => 'inline-flex items-center px-4 py-2 bg-white border border-gray-300 rounded-md font-semibold text-xs text-gray-700 uppercase tracking-widest shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-25 transition ease-in-out duration-150']) }}>
{{ $slot }}
</button>
@@ -1,28 +0,0 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Dashboard') }}
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 grid grid-cols-1 sm:grid-cols-4 gap-6">
<a href="{{ route('zones.index') }}" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
<div class="text-sm text-gray-500">{{ __('Зоны') }}</div>
<div class="text-3xl font-semibold text-gray-900">{{ $zonesCount }}</div>
</a>
<a href="{{ route('devices.index') }}" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
<div class="text-sm text-gray-500">{{ __('Устройства') }}</div>
<div class="text-3xl font-semibold text-gray-900">{{ $devicesCount }}</div>
</a>
<a href="{{ route('devices.index') }}" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
<div class="text-sm text-gray-500">{{ __('Онлайн') }}</div>
<div class="text-3xl font-semibold text-green-600">{{ $onlineDevicesCount }}</div>
</a>
<a href="{{ route('automation-rules.index') }}" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
<div class="text-sm text-gray-500">{{ __('Активных правил') }}</div>
<div class="text-3xl font-semibold text-gray-900">{{ $activeRulesCount }}</div>
</a>
</div>
</div>
</x-app-layout>
@@ -1,56 +0,0 @@
@csrf
@isset($device)
@method('PUT')
@endisset
<div>
<x-input-label for="name" :value="__('Название')" />
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full"
:value="old('name', $device->name ?? '')" required autofocus />
<x-input-error :messages="$errors->get('name')" class="mt-2" />
</div>
<div class="mt-4">
<x-input-label for="zone_id" :value="__('Зона')" />
<select id="zone_id" name="zone_id" required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
<option value="">{{ __('— выбрать —') }}</option>
@foreach ($zones as $zone)
<option value="{{ $zone->id }}" @selected(old('zone_id', $device->zone_id ?? '') == $zone->id)>{{ $zone->name }}</option>
@endforeach
</select>
<x-input-error :messages="$errors->get('zone_id')" class="mt-2" />
</div>
<div class="mt-4">
<x-input-label for="device_type_id" :value="__('Тип устройства')" />
<select id="device_type_id" name="device_type_id" required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
<option value="">{{ __('— выбрать —') }}</option>
@foreach ($deviceTypes as $deviceType)
<option value="{{ $deviceType->id }}" @selected(old('device_type_id', $device->device_type_id ?? '') == $deviceType->id)>
{{ $deviceType->code }} ({{ $deviceType->category->value }})
</option>
@endforeach
</select>
<x-input-error :messages="$errors->get('device_type_id')" class="mt-2" />
</div>
<div class="mt-4">
<x-input-label for="external_id" :value="__('External ID (идентификатор физического устройства)')" />
<x-text-input id="external_id" name="external_id" type="text" class="mt-1 block w-full font-mono"
:value="old('external_id', $device->external_id ?? '')" required />
<x-input-error :messages="$errors->get('external_id')" class="mt-2" />
</div>
<div class="mt-4">
<x-input-label for="protocol" :value="__('Протокол')" />
<x-text-input id="protocol" name="protocol" type="text" class="mt-1 block w-full"
:value="old('protocol', $device->protocol ?? 'mqtt')" required />
<x-input-error :messages="$errors->get('protocol')" class="mt-2" />
</div>
<div class="mt-4 flex items-center gap-4">
<x-primary-button>{{ __('Сохранить') }}</x-primary-button>
<a href="{{ route('devices.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('Отмена') }}</a>
</div>
@@ -1,15 +0,0 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ __('Новое устройство') }}</h2>
</x-slot>
<div class="py-12">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<form method="POST" action="{{ route('devices.store') }}">
@include('devices._form')
</form>
</div>
</div>
</div>
</x-app-layout>
@@ -1,15 +0,0 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ __('Изменить устройство') }}</h2>
</x-slot>
<div class="py-12">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<form method="POST" action="{{ route('devices.update', $device) }}">
@include('devices._form')
</form>
</div>
</div>
</div>
</x-app-layout>
@@ -1,77 +0,0 @@
<x-app-layout>
<x-slot name="header">
<div class="flex justify-between items-center">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Устройства') }}
</h2>
@can('create', \App\Models\Device::class)
<x-primary-button onclick="window.location='{{ route('devices.create') }}'">
{{ __('Добавить устройство') }}
</x-primary-button>
@endcan
</div>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
@if (session('status'))
<div class="mb-4 text-sm text-green-600">{{ session('status') }}</div>
@endif
@if (session('error'))
<div class="mb-4 text-sm text-red-600">{{ session('error') }}</div>
@endif
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2">{{ __('Название') }}</th>
<th class="py-2">{{ __('Зона') }}</th>
<th class="py-2">{{ __('Тип') }}</th>
<th class="py-2">{{ __('Статус') }}</th>
<th class="py-2">{{ __('External ID') }}</th>
<th class="py-2"></th>
</tr>
</thead>
<tbody>
@forelse ($devices as $device)
@php $status = $statuses[$device->external_id] ?? 'unknown'; @endphp
<tr class="border-b">
<td class="py-2">
<a href="{{ route('devices.show', $device) }}" class="text-indigo-600 hover:underline">{{ $device->name }}</a>
</td>
<td class="py-2">{{ $device->zone->name }}</td>
<td class="py-2">{{ $device->deviceType->code }}</td>
<td class="py-2">
<span @class([
'inline-block px-2 py-0.5 rounded text-xs',
'bg-green-100 text-green-800' => $status === 'online',
'bg-gray-100 text-gray-600' => $status === 'offline',
'bg-yellow-100 text-yellow-800' => $status === 'unknown',
])>{{ $status }}</span>
</td>
<td class="py-2 font-mono text-xs">{{ $device->external_id }}</td>
<td class="py-2 text-right space-x-2">
@can('update', $device)
<a href="{{ route('devices.edit', $device) }}" class="text-indigo-600 hover:underline">{{ __('Изменить') }}</a>
@endcan
@can('delete', $device)
<form method="POST" action="{{ route('devices.destroy', $device) }}" class="inline" onsubmit="return confirm('{{ __('Удалить устройство?') }}')">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:underline">{{ __('Удалить') }}</button>
</form>
@endcan
</td>
</tr>
@empty
<tr>
<td colspan="6" class="py-4 text-gray-500">{{ __('Устройств пока нет.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</x-app-layout>
@@ -1,105 +0,0 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ $device->name }}</h2>
</x-slot>
<div class="py-12">
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8 space-y-6">
@if (session('status'))
<div class="text-sm text-green-600">{{ session('status') }}</div>
@endif
@if (session('error'))
<div class="text-sm text-red-600">{{ session('error') }}</div>
@endif
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<h3 class="font-semibold mb-4">{{ __('Живое состояние (Redis device shadow)') }}</h3>
<dl class="grid grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-gray-500">{{ __('Статус') }}</dt>
<dd @class([
'inline-block px-2 py-0.5 rounded text-xs mt-1',
'bg-green-100 text-green-800' => $shadow['status'] === 'online',
'bg-gray-100 text-gray-600' => $shadow['status'] === 'offline',
'bg-yellow-100 text-yellow-800' => $shadow['status'] === 'unknown',
])>{{ $shadow['status'] }}</dd>
</div>
<div>
<dt class="text-gray-500">{{ __('Последняя активность') }}</dt>
<dd>{{ $shadow['last_seen']?->diffForHumans() ?? __('нет данных') }}</dd>
</div>
<div>
<dt class="text-gray-500">{{ __('Желаемое состояние (desired)') }}</dt>
<dd class="font-mono text-xs">{{ json_encode($shadow['desired_state']) }}</dd>
</div>
<div>
<dt class="text-gray-500">{{ __('Подтверждённое состояние (reported)') }}</dt>
<dd class="font-mono text-xs">{{ json_encode($shadow['reported_state']) }}</dd>
</div>
</dl>
</div>
@can('update', $device)
@php $capabilities = $device->deviceType->capabilities ?? []; @endphp
@if (!empty(array_intersect($capabilities, ['turn_on', 'turn_off', 'set_level'])))
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<h3 class="font-semibold mb-4">{{ __('Ручное управление') }}</h3>
<div class="flex items-center gap-4 flex-wrap">
@if (in_array('turn_on', $capabilities))
<form method="POST" action="{{ route('devices.turn-on', $device) }}">
@csrf
<x-primary-button>{{ __('Включить') }}</x-primary-button>
</form>
@endif
@if (in_array('turn_off', $capabilities))
<form method="POST" action="{{ route('devices.turn-off', $device) }}">
@csrf
<x-secondary-button>{{ __('Выключить') }}</x-secondary-button>
</form>
@endif
@if (in_array('set_level', $capabilities))
<form method="POST" action="{{ route('devices.set-level', $device) }}" class="flex items-center gap-2">
@csrf
<input type="number" step="any" name="level" required
class="border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm text-sm"
placeholder="{{ __('уровень') }}">
<x-primary-button>{{ __('Установить уровень') }}</x-primary-button>
</form>
@endif
</div>
</div>
@endif
@endcan
@if ($device->deviceType->category->value === 'sensor')
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<h3 class="font-semibold mb-4">{{ __('Последние показания (ClickHouse)') }}</h3>
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2">{{ __('Тип') }}</th>
<th class="py-2">{{ __('Значение') }}</th>
<th class="py-2">{{ __('Время') }}</th>
</tr>
</thead>
<tbody>
@forelse ($telemetry as $row)
<tr class="border-b">
<td class="py-2">{{ $row['sensor_type'] }}</td>
<td class="py-2">{{ $row['value'] }}</td>
<td class="py-2">{{ $row['recorded_at'] }}</td>
</tr>
@empty
<tr>
<td colspan="3" class="py-4 text-gray-500">{{ __('Показаний пока нет.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@endif
<a href="{{ route('devices.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('← Назад к устройствам') }}</a>
</div>
</div>
</x-app-layout>
@@ -1,118 +0,0 @@
<nav x-data="{ open: false }" class="bg-white border-b border-gray-100">
<!-- Primary Navigation Menu -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<!-- Logo -->
<div class="shrink-0 flex items-center">
<a href="{{ route('dashboard') }}">
<x-application-logo class="block h-9 w-auto fill-current text-gray-800" />
</a>
</div>
<!-- Navigation Links -->
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
{{ __('Dashboard') }}
</x-nav-link>
<x-nav-link :href="route('zones.index')" :active="request()->routeIs('zones.*')">
{{ __('Зоны') }}
</x-nav-link>
<x-nav-link :href="route('devices.index')" :active="request()->routeIs('devices.*')">
{{ __('Устройства') }}
</x-nav-link>
<x-nav-link :href="route('automation-rules.index')" :active="request()->routeIs('automation-rules.*')">
{{ __('Правила') }}
</x-nav-link>
</div>
</div>
<!-- Settings Dropdown -->
<div class="hidden sm:flex sm:items-center sm:ms-6">
<x-dropdown align="right" width="48">
<x-slot name="trigger">
<button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150">
<div>{{ Auth::user()->name }}</div>
<div class="ms-1">
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
</button>
</x-slot>
<x-slot name="content">
<x-dropdown-link :href="route('profile.edit')">
{{ __('Profile') }}
</x-dropdown-link>
<!-- Authentication -->
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-dropdown-link :href="route('logout')"
onclick="event.preventDefault();
this.closest('form').submit();">
{{ __('Log Out') }}
</x-dropdown-link>
</form>
</x-slot>
</x-dropdown>
</div>
<!-- Hamburger -->
<div class="-me-2 flex items-center sm:hidden">
<button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out">
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path :class="{'hidden': open, 'inline-flex': ! open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
<path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<!-- Responsive Navigation Menu -->
<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
<div class="pt-2 pb-3 space-y-1">
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
{{ __('Dashboard') }}
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('zones.index')" :active="request()->routeIs('zones.*')">
{{ __('Зоны') }}
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('devices.index')" :active="request()->routeIs('devices.*')">
{{ __('Устройства') }}
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('automation-rules.index')" :active="request()->routeIs('automation-rules.*')">
{{ __('Правила') }}
</x-responsive-nav-link>
</div>
<!-- Responsive Settings Options -->
<div class="pt-4 pb-1 border-t border-gray-200">
<div class="px-4">
<div class="font-medium text-base text-gray-800">{{ Auth::user()->name }}</div>
<div class="font-medium text-sm text-gray-500">{{ Auth::user()->email }}</div>
</div>
<div class="mt-3 space-y-1">
<x-responsive-nav-link :href="route('profile.edit')">
{{ __('Profile') }}
</x-responsive-nav-link>
<!-- Authentication -->
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-responsive-nav-link :href="route('logout')"
onclick="event.preventDefault();
this.closest('form').submit();">
{{ __('Log Out') }}
</x-responsive-nav-link>
</form>
</div>
</div>
</div>
</nav>
@@ -1,29 +0,0 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Profile') }}
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
<div class="max-w-xl">
@include('profile.partials.update-profile-information-form')
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
<div class="max-w-xl">
@include('profile.partials.update-password-form')
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
<div class="max-w-xl">
@include('profile.partials.delete-user-form')
</div>
</div>
</div>
</div>
</x-app-layout>
@@ -1,55 +0,0 @@
<section class="space-y-6">
<header>
<h2 class="text-lg font-medium text-gray-900">
{{ __('Delete Account') }}
</h2>
<p class="mt-1 text-sm text-gray-600">
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.') }}
</p>
</header>
<x-danger-button
x-data=""
x-on:click.prevent="$dispatch('open-modal', 'confirm-user-deletion')"
>{{ __('Delete Account') }}</x-danger-button>
<x-modal name="confirm-user-deletion" :show="$errors->userDeletion->isNotEmpty()" focusable>
<form method="post" action="{{ route('profile.destroy') }}" class="p-6">
@csrf
@method('delete')
<h2 class="text-lg font-medium text-gray-900">
{{ __('Are you sure you want to delete your account?') }}
</h2>
<p class="mt-1 text-sm text-gray-600">
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
</p>
<div class="mt-6">
<x-input-label for="password" value="{{ __('Password') }}" class="sr-only" />
<x-text-input
id="password"
name="password"
type="password"
class="mt-1 block w-3/4"
placeholder="{{ __('Password') }}"
/>
<x-input-error :messages="$errors->userDeletion->get('password')" class="mt-2" />
</div>
<div class="mt-6 flex justify-end">
<x-secondary-button x-on:click="$dispatch('close')">
{{ __('Cancel') }}
</x-secondary-button>
<x-danger-button class="ms-3">
{{ __('Delete Account') }}
</x-danger-button>
</div>
</form>
</x-modal>
</section>
@@ -1,48 +0,0 @@
<section>
<header>
<h2 class="text-lg font-medium text-gray-900">
{{ __('Update Password') }}
</h2>
<p class="mt-1 text-sm text-gray-600">
{{ __('Ensure your account is using a long, random password to stay secure.') }}
</p>
</header>
<form method="post" action="{{ route('password.update') }}" class="mt-6 space-y-6">
@csrf
@method('put')
<div>
<x-input-label for="update_password_current_password" :value="__('Current Password')" />
<x-text-input id="update_password_current_password" name="current_password" type="password" class="mt-1 block w-full" autocomplete="current-password" />
<x-input-error :messages="$errors->updatePassword->get('current_password')" class="mt-2" />
</div>
<div>
<x-input-label for="update_password_password" :value="__('New Password')" />
<x-text-input id="update_password_password" name="password" type="password" class="mt-1 block w-full" autocomplete="new-password" />
<x-input-error :messages="$errors->updatePassword->get('password')" class="mt-2" />
</div>
<div>
<x-input-label for="update_password_password_confirmation" :value="__('Confirm Password')" />
<x-text-input id="update_password_password_confirmation" name="password_confirmation" type="password" class="mt-1 block w-full" autocomplete="new-password" />
<x-input-error :messages="$errors->updatePassword->get('password_confirmation')" class="mt-2" />
</div>
<div class="flex items-center gap-4">
<x-primary-button>{{ __('Save') }}</x-primary-button>
@if (session('status') === 'password-updated')
<p
x-data="{ show: true }"
x-show="show"
x-transition
x-init="setTimeout(() => show = false, 2000)"
class="text-sm text-gray-600"
>{{ __('Saved.') }}</p>
@endif
</div>
</form>
</section>
@@ -1,64 +0,0 @@
<section>
<header>
<h2 class="text-lg font-medium text-gray-900">
{{ __('Profile Information') }}
</h2>
<p class="mt-1 text-sm text-gray-600">
{{ __("Update your account's profile information and email address.") }}
</p>
</header>
<form id="send-verification" method="post" action="{{ route('verification.send') }}">
@csrf
</form>
<form method="post" action="{{ route('profile.update') }}" class="mt-6 space-y-6">
@csrf
@method('patch')
<div>
<x-input-label for="name" :value="__('Name')" />
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full" :value="old('name', $user->name)" required autofocus autocomplete="name" />
<x-input-error class="mt-2" :messages="$errors->get('name')" />
</div>
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" name="email" type="email" class="mt-1 block w-full" :value="old('email', $user->email)" required autocomplete="username" />
<x-input-error class="mt-2" :messages="$errors->get('email')" />
@if ($user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && ! $user->hasVerifiedEmail())
<div>
<p class="text-sm mt-2 text-gray-800">
{{ __('Your email address is unverified.') }}
<button form="send-verification" class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
{{ __('Click here to re-send the verification email.') }}
</button>
</p>
@if (session('status') === 'verification-link-sent')
<p class="mt-2 font-medium text-sm text-green-600">
{{ __('A new verification link has been sent to your email address.') }}
</p>
@endif
</div>
@endif
</div>
<div class="flex items-center gap-4">
<x-primary-button>{{ __('Save') }}</x-primary-button>
@if (session('status') === 'profile-updated')
<p
x-data="{ show: true }"
x-show="show"
x-transition
x-init="setTimeout(() => show = false, 2000)"
class="text-sm text-gray-600"
>{{ __('Saved.') }}</p>
@endif
</div>
</form>
</section>
@@ -1,23 +0,0 @@
@csrf
@isset($zone)
@method('PUT')
@endisset
<div>
<x-input-label for="name" :value="__('Название')" />
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full"
:value="old('name', $zone->name ?? '')" required autofocus />
<x-input-error :messages="$errors->get('name')" class="mt-2" />
</div>
<div class="mt-4">
<x-input-label for="description" :value="__('Описание')" />
<textarea id="description" name="description" rows="3"
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">{{ old('description', $zone->description ?? '') }}</textarea>
<x-input-error :messages="$errors->get('description')" class="mt-2" />
</div>
<div class="mt-4 flex items-center gap-4">
<x-primary-button>{{ __('Сохранить') }}</x-primary-button>
<a href="{{ route('zones.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('Отмена') }}</a>
</div>
@@ -1,15 +0,0 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ __('Новая зона') }}</h2>
</x-slot>
<div class="py-12">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<form method="POST" action="{{ route('zones.store') }}">
@include('zones._form')
</form>
</div>
</div>
</div>
</x-app-layout>
@@ -1,15 +0,0 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ __('Изменить зону') }}</h2>
</x-slot>
<div class="py-12">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<form method="POST" action="{{ route('zones.update', $zone) }}">
@include('zones._form')
</form>
</div>
</div>
</div>
</x-app-layout>
@@ -1,60 +0,0 @@
<x-app-layout>
<x-slot name="header">
<div class="flex justify-between items-center">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Зоны') }}
</h2>
@can('create', \App\Models\Zone::class)
<x-primary-button onclick="window.location='{{ route('zones.create') }}'">
{{ __('Добавить зону') }}
</x-primary-button>
@endcan
</div>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
@if (session('status'))
<div class="mb-4 text-sm text-green-600">{{ session('status') }}</div>
@endif
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2">{{ __('Название') }}</th>
<th class="py-2">{{ __('Описание') }}</th>
<th class="py-2">{{ __('Устройств') }}</th>
<th class="py-2"></th>
</tr>
</thead>
<tbody>
@forelse ($zones as $zone)
<tr class="border-b">
<td class="py-2">{{ $zone->name }}</td>
<td class="py-2 text-gray-500">{{ $zone->description }}</td>
<td class="py-2">{{ $zone->devices_count }}</td>
<td class="py-2 text-right space-x-2">
@can('update', $zone)
<a href="{{ route('zones.edit', $zone) }}" class="text-indigo-600 hover:underline">{{ __('Изменить') }}</a>
@endcan
@can('delete', $zone)
<form method="POST" action="{{ route('zones.destroy', $zone) }}" class="inline" onsubmit="return confirm('{{ __('Удалить зону?') }}')">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:underline">{{ __('Удалить') }}</button>
</form>
@endcan
</td>
</tr>
@empty
<tr>
<td colspan="4" class="py-4 text-gray-500">{{ __('Зон пока нет.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</x-app-layout>
+34 -4
View File
@@ -1,8 +1,38 @@
<?php <?php
use Illuminate\Http\Request; use App\Http\Controllers\Api\V1\AuthController;
use App\Http\Controllers\Api\V1\AutomationRuleController;
use App\Http\Controllers\Api\V1\DeviceController;
use App\Http\Controllers\Api\V1\DeviceTypeController;
use App\Http\Controllers\Api\V1\ZoneController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/user', function (Request $request) { Route::prefix('v1')->group(function () {
return $request->user(); Route::post('/login', [AuthController::class, 'login']);
})->middleware('auth:sanctum');
Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', [AuthController::class, 'logout']);
Route::get('/me', [AuthController::class, 'me']);
Route::get('/zones', [ZoneController::class, 'index']);
Route::post('/zones', [ZoneController::class, 'store']);
Route::put('/zones/{zone}', [ZoneController::class, 'update']);
Route::delete('/zones/{zone}', [ZoneController::class, 'destroy']);
Route::get('/device-types', [DeviceTypeController::class, 'index']);
Route::get('/devices', [DeviceController::class, 'index']);
Route::post('/devices', [DeviceController::class, 'store']);
Route::get('/devices/{device}', [DeviceController::class, 'show']);
Route::put('/devices/{device}', [DeviceController::class, 'update']);
Route::delete('/devices/{device}', [DeviceController::class, 'destroy']);
Route::post('/devices/{device}/turn-on', [DeviceController::class, 'turnOn']);
Route::post('/devices/{device}/turn-off', [DeviceController::class, 'turnOff']);
Route::post('/devices/{device}/set-level', [DeviceController::class, 'setLevel']);
Route::get('/automation-rules', [AutomationRuleController::class, 'index']);
Route::post('/automation-rules', [AutomationRuleController::class, 'store']);
Route::put('/automation-rules/{automation_rule}', [AutomationRuleController::class, 'update']);
Route::delete('/automation-rules/{automation_rule}', [AutomationRuleController::class, 'destroy']);
});
});
+3 -2
View File
@@ -9,6 +9,7 @@ use App\Models\Device;
use App\Models\Zone; use App\Models\Zone;
use App\Services\DeviceShadow; use App\Services\DeviceShadow;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
Route::get('/', function () { Route::get('/', function () {
return view('welcome'); return view('welcome');
@@ -20,7 +21,7 @@ Route::get('/dashboard', function (DeviceShadow $shadow) {
->filter(fn (string $status) => $status === 'online') ->filter(fn (string $status) => $status === 'online')
->count(); ->count();
return view('dashboard', [ return Inertia::render('Dashboard', [
'zonesCount' => Zone::count(), 'zonesCount' => Zone::count(),
'devicesCount' => $devices->count(), 'devicesCount' => $devices->count(),
'onlineDevicesCount' => $onlineCount, 'onlineDevicesCount' => $onlineCount,
@@ -29,7 +30,7 @@ Route::get('/dashboard', function (DeviceShadow $shadow) {
})->middleware(['auth', 'verified'])->name('dashboard'); })->middleware(['auth', 'verified'])->name('dashboard');
Route::middleware(['auth', 'verified'])->group(function () { 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('devices', DeviceController::class);
Route::resource('automation-rules', AutomationRuleController::class) Route::resource('automation-rules', AutomationRuleController::class)
->except('show') ->except('show')
+1
View File
@@ -7,6 +7,7 @@ export default {
'./vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php', './vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php',
'./storage/framework/views/*.php', './storage/framework/views/*.php',
'./resources/views/**/*.blade.php', './resources/views/**/*.blade.php',
'./resources/js/**/*.vue',
], ],
theme: { theme: {
@@ -0,0 +1,82 @@
<?php
namespace Tests\Feature\Api\V1;
use App\Enums\UserRole;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AuthTest extends TestCase
{
use RefreshDatabase;
public function test_login_issues_a_token(): void
{
$user = User::factory()->create([
'role' => UserRole::Owner,
'password' => bcrypt('secret1234'),
]);
$response = $this->postJson('/api/v1/login', [
'email' => $user->email,
'password' => 'secret1234',
'device_name' => 'iphone-15',
]);
$response->assertOk();
$response->assertJsonStructure(['token', 'user' => ['id', 'name', 'email', 'role']]);
$this->assertDatabaseHas('personal_access_tokens', [
'tokenable_id' => $user->id,
'name' => 'iphone-15',
]);
}
public function test_login_rejects_wrong_password(): void
{
$user = User::factory()->create(['password' => bcrypt('secret1234')]);
$response = $this->postJson('/api/v1/login', [
'email' => $user->email,
'password' => 'wrong-password',
'device_name' => 'iphone-15',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors('email');
}
public function test_authenticated_token_can_reach_protected_route(): void
{
$user = User::factory()->create(['password' => bcrypt('secret1234')]);
$token = $this->postJson('/api/v1/login', [
'email' => $user->email,
'password' => 'secret1234',
'device_name' => 'iphone-15',
])->json('token');
$this->withHeader('Authorization', "Bearer {$token}")
->getJson('/api/v1/me')
->assertOk()
->assertJsonPath('data.email', $user->email);
}
public function test_logout_revokes_the_current_token(): void
{
$user = User::factory()->create(['password' => bcrypt('secret1234')]);
$tokenModel = $user->createToken('iphone-15');
$this->withHeader('Authorization', "Bearer {$tokenModel->plainTextToken}")
->postJson('/api/v1/logout')
->assertNoContent();
$this->assertDatabaseMissing('personal_access_tokens', ['id' => $tokenModel->accessToken->id]);
}
public function test_guest_cannot_reach_protected_route(): void
{
$this->getJson('/api/v1/me')->assertUnauthorized();
}
}
@@ -0,0 +1,124 @@
<?php
namespace Tests\Feature\Api\V1;
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 Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AutomationRuleApiTest extends TestCase
{
use RefreshDatabase;
private function makeZoneWithDevices(User $owner): array
{
$zone = Zone::forceCreate(['user_id' => $owner->id, 'name' => 'Гроубокс']);
$sensorType = DeviceType::create([
'code' => 'sensor_temp_humidity',
'category' => DeviceCategory::Sensor,
'capabilities' => ['temperature', 'humidity'],
]);
$fanType = DeviceType::create([
'code' => 'fan',
'category' => DeviceCategory::Actuator,
'capabilities' => ['turn_on', 'turn_off', 'set_level'],
]);
$sensor = Device::forceCreate([
'user_id' => $owner->id, 'zone_id' => $zone->id, 'device_type_id' => $sensorType->id,
'name' => 'Датчик', 'external_id' => 'sensor-1', 'protocol' => 'mqtt',
]);
$fan = Device::forceCreate([
'user_id' => $owner->id, 'zone_id' => $zone->id, 'device_type_id' => $fanType->id,
'name' => 'Вентилятор', 'external_id' => 'fan-1', 'protocol' => 'mqtt',
]);
return [$zone, $sensor, $fan];
}
public function test_owner_can_create_rule_with_turn_on_action(): void
{
$owner = User::factory()->create(['role' => UserRole::Owner]);
[$zone, $sensor, $fan] = $this->makeZoneWithDevices($owner);
$response = $this->actingAs($owner)->postJson('/api/v1/automation-rules', [
'zone_id' => $zone->id,
'condition_source_device_id' => $sensor->id,
'condition_sensor_type' => 'temperature',
'condition_operator' => '>',
'condition_value' => 28,
'target_device_id' => $fan->id,
'action_type' => 'turn_on',
'is_active' => true,
]);
$response->assertCreated();
// '{}', not '[]' — same PHP/Go empty-array-vs-object gotcha as the web controller.
$this->assertDatabaseHas('automation_rules', [
'target_device_id' => $fan->id,
'action_type' => 'turn_on',
'action_params' => '{}',
]);
}
public function test_set_level_action_requires_and_stores_level_param(): void
{
$owner = User::factory()->create(['role' => UserRole::Owner]);
[$zone, $sensor, $fan] = $this->makeZoneWithDevices($owner);
$missingLevel = $this->actingAs($owner)->postJson('/api/v1/automation-rules', [
'zone_id' => $zone->id,
'condition_source_device_id' => $sensor->id,
'condition_sensor_type' => 'temperature',
'condition_operator' => '>',
'condition_value' => 28,
'target_device_id' => $fan->id,
'action_type' => 'set_level',
]);
$missingLevel->assertJsonValidationErrors('level');
$response = $this->actingAs($owner)->postJson('/api/v1/automation-rules', [
'zone_id' => $zone->id,
'condition_source_device_id' => $sensor->id,
'condition_sensor_type' => 'temperature',
'condition_operator' => '>',
'condition_value' => 28,
'target_device_id' => $fan->id,
'action_type' => 'set_level',
'level' => 42,
]);
$response->assertCreated();
$this->assertDatabaseHas('automation_rules', [
'target_device_id' => $fan->id,
'action_type' => 'set_level',
'action_params' => '{"level":42}',
]);
}
public function test_viewer_cannot_create_rule(): void
{
$owner = User::factory()->create(['role' => UserRole::Owner]);
$viewer = User::factory()->create(['role' => UserRole::Viewer]);
[$zone, $sensor, $fan] = $this->makeZoneWithDevices($owner);
$response = $this->actingAs($viewer)->postJson('/api/v1/automation-rules', [
'zone_id' => $zone->id,
'condition_source_device_id' => $sensor->id,
'condition_sensor_type' => 'temperature',
'condition_operator' => '>',
'condition_value' => 28,
'target_device_id' => $fan->id,
'action_type' => 'turn_on',
]);
$response->assertForbidden();
$this->assertDatabaseCount('automation_rules', 0);
}
}
@@ -0,0 +1,138 @@
<?php
namespace Tests\Feature\Api\V1;
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\ClickHouseClient;
use App\Services\DeviceControlClient;
use App\Services\DeviceShadow;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DeviceApiTest extends TestCase
{
use RefreshDatabase;
private function makeDevice(User $owner, array $capabilities, string $category = DeviceCategory::Actuator->value): Device
{
$zone = Zone::forceCreate(['user_id' => $owner->id, 'name' => 'Гроубокс']);
$type = DeviceType::create([
'code' => 'fan',
'category' => $category,
'capabilities' => $capabilities,
]);
return Device::forceCreate([
'user_id' => $owner->id, 'zone_id' => $zone->id, 'device_type_id' => $type->id,
'name' => 'Вентилятор', 'external_id' => 'fan-1', 'protocol' => 'mqtt',
]);
}
public function test_index_includes_live_status_from_shadow(): void
{
$owner = User::factory()->create(['role' => UserRole::Owner]);
$device = $this->makeDevice($owner, ['turn_on']);
$this->mock(DeviceShadow::class, function ($mock) {
$mock->shouldReceive('statuses')->once()->with(['fan-1'])->andReturn(['fan-1' => 'online']);
});
$response = $this->actingAs($owner)->getJson('/api/v1/devices');
$response->assertOk();
$response->assertJsonPath('data.0.status', 'online');
$response->assertJsonPath('data.0.external_id', $device->external_id);
}
public function test_show_includes_shadow_snapshot_and_telemetry(): void
{
$owner = User::factory()->create(['role' => UserRole::Owner]);
$device = $this->makeDevice($owner, ['temperature'], DeviceCategory::Sensor->value);
$this->mock(DeviceShadow::class, function ($mock) {
$mock->shouldReceive('snapshot')->once()->with('fan-1')->andReturn([
'status' => 'online',
'last_seen' => null,
'desired_state' => [],
'reported_state' => ['temperature' => 24.5],
]);
});
$this->mock(ClickHouseClient::class, function ($mock) {
$mock->shouldReceive('query')->once()
->andReturn([['sensor_type' => 'temperature', 'value' => 24.5, 'recorded_at' => '2026-01-01 00:00:00']]);
});
$response = $this->actingAs($owner)->getJson("/api/v1/devices/{$device->id}");
$response->assertOk();
$response->assertJsonPath('data.status', 'online');
$response->assertJsonPath('shadow.reported_state.temperature', 24.5);
$response->assertJsonPath('telemetry.0.sensor_type', 'temperature');
}
public function test_owner_can_turn_on_device(): void
{
$owner = User::factory()->create(['role' => UserRole::Owner]);
$device = $this->makeDevice($owner, ['turn_on', 'turn_off']);
$this->mock(DeviceControlClient::class, function ($mock) {
$mock->shouldReceive('turnOn')->once()->with('fan-1')
->andReturn(['success' => true, 'error' => null]);
});
$response = $this->actingAs($owner)->postJson("/api/v1/devices/{$device->id}/turn-on");
$response->assertOk();
$response->assertJsonPath('message', 'Команда отправлена.');
}
public function test_turn_on_rejected_when_device_lacks_capability(): void
{
$owner = User::factory()->create(['role' => UserRole::Owner]);
$device = $this->makeDevice($owner, ['turn_off']); // no turn_on
$this->mock(DeviceControlClient::class, function ($mock) {
$mock->shouldNotReceive('turnOn');
});
$response = $this->actingAs($owner)->postJson("/api/v1/devices/{$device->id}/turn-on");
$response->assertStatus(422);
}
public function test_viewer_cannot_turn_on_device(): void
{
$owner = User::factory()->create(['role' => UserRole::Owner]);
$viewer = User::factory()->create(['role' => UserRole::Viewer]);
$device = $this->makeDevice($owner, ['turn_on']);
$this->mock(DeviceControlClient::class, function ($mock) {
$mock->shouldNotReceive('turnOn');
});
$response = $this->actingAs($viewer)->postJson("/api/v1/devices/{$device->id}/turn-on");
$response->assertForbidden();
}
public function test_failed_command_returns_422_with_message(): void
{
$owner = User::factory()->create(['role' => UserRole::Owner]);
$device = $this->makeDevice($owner, ['turn_on']);
$this->mock(DeviceControlClient::class, function ($mock) {
$mock->shouldReceive('turnOn')->once()
->andReturn(['success' => false, 'error' => 'device offline']);
});
$response = $this->actingAs($owner)->postJson("/api/v1/devices/{$device->id}/turn-on");
$response->assertStatus(422);
$response->assertJsonPath('message', 'device offline');
}
}
@@ -0,0 +1,65 @@
<?php
namespace Tests\Feature\Api\V1;
use App\Enums\UserRole;
use App\Models\User;
use App\Models\Zone;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ZoneApiTest extends TestCase
{
use RefreshDatabase;
public function test_owner_can_create_zone(): void
{
$owner = User::factory()->create(['role' => UserRole::Owner]);
$response = $this->actingAs($owner)->postJson('/api/v1/zones', [
'name' => 'Гостиная',
'description' => 'Тест',
]);
$response->assertCreated();
$response->assertJsonPath('data.name', 'Гостиная');
$this->assertDatabaseHas('zones', ['name' => 'Гостиная', 'user_id' => $owner->id]);
}
public function test_viewer_cannot_create_zone(): void
{
$viewer = User::factory()->create(['role' => UserRole::Viewer]);
$response = $this->actingAs($viewer)->postJson('/api/v1/zones', ['name' => 'Гостиная']);
$response->assertForbidden();
$this->assertDatabaseMissing('zones', ['name' => 'Гостиная']);
}
public function test_viewer_can_list_zones(): void
{
$viewer = User::factory()->create(['role' => UserRole::Viewer]);
Zone::forceCreate(['user_id' => $viewer->id, 'name' => 'Гроубокс']);
$response = $this->actingAs($viewer)->getJson('/api/v1/zones');
$response->assertOk();
$response->assertJsonPath('data.0.name', 'Гроубокс');
}
public function test_viewer_cannot_delete_zone(): void
{
$viewer = User::factory()->create(['role' => UserRole::Viewer]);
$zone = Zone::forceCreate(['user_id' => $viewer->id, 'name' => 'Гроубокс']);
$response = $this->actingAs($viewer)->deleteJson("/api/v1/zones/{$zone->id}");
$response->assertForbidden();
$this->assertDatabaseHas('zones', ['id' => $zone->id]);
}
public function test_guest_gets_401(): void
{
$this->getJson('/api/v1/zones')->assertUnauthorized();
}
}
@@ -12,6 +12,7 @@ use App\Services\ClickHouseClient;
use App\Services\DeviceControlClient; use App\Services\DeviceControlClient;
use App\Services\DeviceShadow; use App\Services\DeviceShadow;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase; use Tests\TestCase;
class DeviceControllerTest extends TestCase class DeviceControllerTest extends TestCase
@@ -123,7 +124,10 @@ class DeviceControllerTest extends TestCase
$response = $this->actingAs($owner)->get(route('devices.show', $device)); $response = $this->actingAs($owner)->get(route('devices.show', $device));
$response->assertOk(); $response->assertOk();
$response->assertSee('online'); $response->assertInertia(fn (Assert $page) => $page
$response->assertSee('24.5'); ->component('Devices/Show')
->where('device.status', 'online')
->where('telemetry.0.value', 24.5)
);
} }
} }
@@ -2,10 +2,15 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Enums\DeviceCategory;
use App\Enums\UserRole; use App\Enums\UserRole;
use App\Models\Device;
use App\Models\DeviceType;
use App\Models\User; use App\Models\User;
use App\Models\Zone; use App\Models\Zone;
use App\Services\DeviceShadow;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase; use Tests\TestCase;
class ZoneControllerTest extends TestCase class ZoneControllerTest extends TestCase
@@ -45,7 +50,39 @@ class ZoneControllerTest extends TestCase
$response = $this->actingAs($viewer)->get(route('zones.index')); $response = $this->actingAs($viewer)->get(route('zones.index'));
$response->assertOk(); $response->assertOk();
$response->assertSee('Гроубокс'); $response->assertInertia(fn (Assert $page) => $page
->component('Zones/Index')
->where('zones.0.name', 'Гроубокс')
);
}
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 public function test_viewer_cannot_delete_zone(): void
+15
View File
@@ -1,11 +1,26 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin'; import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({ export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./resources/js', import.meta.url)),
},
},
plugins: [ plugins: [
laravel({ laravel({
input: ['resources/css/app.css', 'resources/js/app.js'], input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true, refresh: true,
}), }),
vue({
template: {
transformAssetUrls: {
base: null,
includeAbsolute: false,
},
},
}),
], ],
}); });