Compare commits

...
Author SHA1 Message Date
cacto d10f95182d Управление типами устройств из UI (список + создание)
Раньше device_types заводились только через сидер — теперь owner может
посмотреть все типы (карточки: иконка по категории, capabilities
тегами, число устройств этого типа) и добавить новый прямо из
интерфейса: код, категория, возможности вводятся тегами (можно
вписать вручную и добавить по Enter/кнопке). Ссылка на список — со
страницы устройств.

DeviceTypePolicy зеркалит остальные Policy (view — всем, create —
только owner). DeviceTypeResource получил devices_count через
whenCounted — опционально, не ломает существующий API-эндпоинт
(там withCount не вызывается, поле просто не появляется).
2026-08-18 03:09:47 +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
78 changed files with 2363 additions and 1232 deletions
@@ -2,12 +2,18 @@
namespace App\Http\Controllers;
use App\Enums\ConditionOperator;
use App\Enums\DeviceCategory;
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\Device;
use App\Models\DeviceType;
use App\Models\Zone;
use Illuminate\Support\Collection;
use Inertia\Inertia;
class AutomationRuleController extends Controller
{
@@ -19,14 +25,16 @@ class AutomationRuleController extends Controller
->orderByDesc('id')
->get();
return view('automation-rules.index', ['rules' => $rules]);
return Inertia::render('AutomationRules/Index', [
'rules' => AutomationRuleResource::collection($rules)->resolve(),
]);
}
public function create()
{
$this->authorize('create', AutomationRule::class);
return view('automation-rules.create', $this->formOptions());
return Inertia::render('AutomationRules/Create', $this->formOptions());
}
public function store(AutomationRuleRequest $request)
@@ -42,8 +50,19 @@ class AutomationRuleController extends Controller
{
$this->authorize('update', $automation_rule);
return view('automation-rules.edit', [
'rule' => $automation_rule,
return Inertia::render('AutomationRules/Edit', [
'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(),
]);
}
@@ -91,10 +110,11 @@ class AutomationRuleController extends Controller
$devices = Device::with(['zone', 'deviceType'])->orderBy('name')->get();
return [
'zones' => Zone::orderBy('name')->get(),
'devices' => $devices,
'zones' => ZoneResource::collection(Zone::orderBy('name')->get())->resolve(),
'devices' => DeviceResource::collection($devices)->resolve(),
'actionTypeOptions' => $this->capabilityOptions(DeviceCategory::Actuator),
'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
* 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)
{
@@ -3,6 +3,9 @@
namespace App\Http\Controllers;
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\DeviceType;
use App\Models\Zone;
@@ -10,6 +13,7 @@ use App\Services\ClickHouseClient;
use App\Services\DeviceControlClient;
use App\Services\DeviceShadow;
use Illuminate\Http\Request;
use Inertia\Inertia;
class DeviceController extends Controller
{
@@ -19,8 +23,11 @@ class DeviceController extends Controller
$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 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)
@@ -28,6 +35,8 @@ class DeviceController extends Controller
$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') {
@@ -39,9 +48,13 @@ class DeviceController extends Controller
);
}
return view('devices.show', [
'device' => $device,
'shadow' => $shadow->snapshot($device->external_id),
return Inertia::render('Devices/Show', [
'device' => (new DeviceResource($device))->resolve(),
'shadow' => [
'last_seen' => $snapshot['last_seen'],
'desired_state' => $snapshot['desired_state'],
'reported_state' => $snapshot['reported_state'],
],
'telemetry' => $telemetry,
]);
}
@@ -50,7 +63,7 @@ class DeviceController extends Controller
{
$this->authorize('create', Device::class);
return view('devices.create', $this->formOptions());
return Inertia::render('Devices/Create', $this->formOptions());
}
public function store(DeviceRequest $request)
@@ -66,7 +79,10 @@ class DeviceController extends Controller
{
$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)
@@ -137,8 +153,8 @@ class DeviceController extends Controller
private function formOptions(): array
{
return [
'zones' => Zone::orderBy('name')->get(),
'deviceTypes' => DeviceType::orderBy('code')->get(),
'zones' => ZoneResource::collection(Zone::orderBy('name')->get())->resolve(),
'deviceTypes' => DeviceTypeResource::collection(DeviceType::orderBy('code')->get())->resolve(),
];
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\DeviceTypeRequest;
use App\Http\Resources\DeviceTypeResource;
use App\Models\DeviceType;
use Inertia\Inertia;
class DeviceTypeController extends Controller
{
public function index()
{
$this->authorize('viewAny', DeviceType::class);
$deviceTypes = DeviceType::withCount('devices')->orderBy('code')->get();
return Inertia::render('DeviceTypes/Index', [
'deviceTypes' => DeviceTypeResource::collection($deviceTypes)->resolve(),
]);
}
public function create()
{
$this->authorize('create', DeviceType::class);
return Inertia::render('DeviceTypes/Create');
}
public function store(DeviceTypeRequest $request)
{
DeviceType::create($request->validated());
return redirect()->route('device-types.index')->with('status', 'Тип устройства создан.');
}
}
@@ -7,17 +7,18 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\View\View;
use Inertia\Inertia;
use Inertia\Response;
class ProfileController extends Controller
{
/**
* Display the user's profile form.
*/
public function edit(Request $request): View
public function edit(Request $request): Response
{
return view('profile.edit', [
'user' => $request->user(),
return Inertia::render('Profile/Edit', [
'user' => $request->user()->only(['id', 'name', 'email']),
]);
}
@@ -3,7 +3,9 @@
namespace App\Http\Controllers;
use App\Http\Requests\ZoneRequest;
use App\Http\Resources\ZoneResource;
use App\Models\Zone;
use Inertia\Inertia;
class ZoneController extends Controller
{
@@ -13,14 +15,16 @@ class ZoneController extends Controller
$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 create()
{
$this->authorize('create', Zone::class);
return view('zones.create');
return Inertia::render('Zones/Create');
}
public function store(ZoneRequest $request)
@@ -34,7 +38,9 @@ class ZoneController extends Controller
{
$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)
@@ -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,29 @@
<?php
namespace App\Http\Requests;
use App\Enums\DeviceCategory;
use App\Models\DeviceType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class DeviceTypeRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', DeviceType::class);
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'code' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('device_types', 'code')],
'category' => ['required', Rule::enum(DeviceCategory::class)],
'capabilities' => ['array'],
'capabilities.*' => ['string', 'max:255'],
];
}
}
@@ -14,9 +14,11 @@ class AutomationRuleResource extends JsonResource
{
return [
'id' => $this->id,
'zone' => new ZoneResource($this->whenLoaded('zone')),
'target_device' => new DeviceResource($this->whenLoaded('targetDevice')),
'condition_source_device' => new DeviceResource($this->whenLoaded('conditionSourceDevice')),
// 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,
@@ -25,8 +25,13 @@ class DeviceResource extends JsonResource
'external_id' => $this->external_id,
'protocol' => $this->protocol,
'status' => $this->live_status ?? $this->status,
'zone' => new ZoneResource($this->whenLoaded('zone')),
'device_type' => new DeviceTypeResource($this->whenLoaded('deviceType')),
// 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,
];
}
@@ -17,6 +17,7 @@ class DeviceTypeResource extends JsonResource
'code' => $this->code,
'category' => $this->category->value,
'capabilities' => $this->capabilities,
'devices_count' => $this->whenCounted('devices'),
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Policies;
use App\Models\DeviceType;
use App\Models\User;
/** See ZonePolicy for the household-wide RBAC rationale. */
class DeviceTypePolicy
{
public function viewAny(User $user): bool
{
return true;
}
public function view(User $user, DeviceType $deviceType): bool
{
return true;
}
public function create(User $user): bool
{
return $user->isOwner();
}
}
@@ -2,6 +2,7 @@
namespace App\Services;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@@ -43,7 +44,7 @@ class DeviceControlClient
{
try {
$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()]);
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
use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
@@ -13,7 +14,9 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
$middleware->web(append: [
HandleInertiaRequests::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
+1
View File
@@ -7,6 +7,7 @@
"license": "MIT",
"require": {
"php": "^8.3",
"inertiajs/inertia-laravel": "^3.3",
"laravel/framework": "^13.8",
"laravel/sanctum": "^4.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",
"This file is @generated automatically"
],
"content-hash": "483f1bf17688fcc178b8c1849ff31ddc",
"content-hash": "3c4f6c9d2bddd0693e70d6396ea65c43",
"packages": [
{
"name": "brick/math",
@@ -1057,6 +1057,78 @@
],
"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",
"version": "v13.22.0",
@@ -2,6 +2,7 @@
namespace Database\Factories;
use App\Enums\UserRole;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
@@ -30,6 +31,10 @@ class UserFactory extends Factory
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'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,
"packages": {
"": {
"dependencies": {
"@inertiajs/vue3": "^3.6.1",
"@vitejs/plugin-vue": "^6.0.8",
"vue": "^3.5.41"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.2",
"@tailwindcss/vite": "^4.0.0",
"alpinejs": "^3.4.2",
"autoprefixer": "^10.4.2",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^3.1",
@@ -29,11 +33,56 @@
"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": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -45,7 +94,6 @@
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -56,13 +104,45 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"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": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -89,7 +169,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -99,14 +178,12 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -117,7 +194,6 @@
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -174,7 +250,6 @@
"version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
@@ -187,7 +262,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -204,7 +278,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -221,7 +294,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -238,7 +310,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -255,7 +326,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -272,7 +342,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -289,7 +358,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -306,7 +374,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -323,7 +390,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -340,7 +406,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -357,7 +422,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -374,7 +438,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -391,7 +454,6 @@
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -410,7 +472,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -427,7 +488,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -441,7 +501,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/forms": {
@@ -747,40 +806,171 @@
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@vue/reactivity": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
"dev": true,
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.8",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz",
"integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==",
"license": "MIT",
"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": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
"dev": true,
"node_modules/@vue/compiler-core": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz",
"integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==",
"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"
},
"node_modules/alpinejs": {
"version": "3.15.12",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz",
"integrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==",
"dev": true,
"node_modules/@vue/compiler-dom": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz",
"integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==",
"license": "MIT",
"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": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -1127,11 +1317,16 @@
"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": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -1179,6 +1374,18 @@
"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": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
@@ -1189,6 +1396,17 @@
"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": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1199,6 +1417,12 @@
"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": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -1270,7 +1494,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -1420,12 +1643,29 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"bin": {
"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": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.3.tgz",
@@ -1457,7 +1697,6 @@
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
@@ -1490,7 +1729,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1511,7 +1749,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1532,7 +1769,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1553,7 +1789,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1574,7 +1809,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1595,7 +1829,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1616,7 +1849,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1637,7 +1869,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1658,7 +1889,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1679,7 +1909,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1700,7 +1929,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1738,7 +1966,6 @@
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -1791,10 +2018,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true,
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
@@ -1860,7 +2086,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
@@ -1900,7 +2125,6 @@
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -2150,7 +2374,6 @@
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.139.0",
@@ -2231,7 +2454,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -2406,7 +2628,6 @@
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@@ -2423,7 +2644,6 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@@ -2441,7 +2661,6 @@
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -2484,7 +2703,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"devOptional": true,
"license": "0BSD"
},
"node_modules/update-browserslist-db": {
@@ -2529,7 +2748,6 @@
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
@@ -2618,7 +2836,6 @@
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -2627,6 +2844,33 @@
"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": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+5 -1
View File
@@ -9,12 +9,16 @@
"devDependencies": {
"@tailwindcss/forms": "^0.5.2",
"@tailwindcss/vite": "^4.0.0",
"alpinejs": "^3.4.2",
"autoprefixer": "^10.4.2",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^3.1",
"postcss": "^8.4.31",
"tailwindcss": "^3.1.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,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,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,124 @@
<script setup>
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
import { routes } from '@/routes';
const form = useForm({
code: '',
category: '',
capabilities: [],
});
const capabilityInput = ref('');
function addCapability() {
const value = capabilityInput.value.trim();
if (value && !form.capabilities.includes(value)) {
form.capabilities.push(value);
}
capabilityInput.value = '';
}
function removeCapability(index) {
form.capabilities.splice(index, 1);
}
function submit() {
form.post(routes.deviceTypes.store);
}
</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 @submit.prevent="submit">
<div>
<label for="code" class="block font-medium text-sm text-gray-700">Код</label>
<input
id="code"
v-model="form.code"
type="text"
required
autofocus
placeholder="например, smart_socket"
class="mt-1 block w-full font-mono border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<p class="mt-1 text-xs text-gray-400">Только латиница, цифры, дефис и подчёркивание используется как идентификатор.</p>
<div v-if="form.errors.code" class="mt-2 text-sm text-red-600">{{ form.errors.code }}</div>
</div>
<div class="mt-4">
<label for="category" class="block font-medium text-sm text-gray-700">Категория</label>
<select
id="category"
v-model="form.category"
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 value="sensor">Датчик</option>
<option value="actuator">Исполнитель</option>
</select>
<div v-if="form.errors.category" class="mt-2 text-sm text-red-600">{{ form.errors.category }}</div>
</div>
<div class="mt-4">
<label for="capability" class="block font-medium text-sm text-gray-700">Возможности</label>
<p class="mt-1 text-xs text-gray-400">
Для датчика типы показаний (например, temperature). Для исполнителя команды
(например, turn_on, turn_off, set_level).
</p>
<div class="mt-2 flex gap-2">
<input
id="capability"
v-model="capabilityInput"
type="text"
placeholder="turn_on"
class="block w-full font-mono border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
@keydown.enter.prevent="addCapability"
>
<button
type="button"
class="shrink-0 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="addCapability"
>
Добавить
</button>
</div>
<div v-if="form.capabilities.length" class="mt-3 flex flex-wrap gap-1.5">
<span
v-for="(capability, index) in form.capabilities"
:key="capability"
class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600"
>
{{ capability }}
<button type="button" class="text-gray-400 hover:text-red-600" @click="removeCapability(index)">×</button>
</span>
</div>
<div v-if="form.errors.capabilities" class="mt-2 text-sm text-red-600">{{ form.errors.capabilities }}</div>
</div>
<div class="mt-6 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.deviceTypes.index" class="text-sm text-gray-600 hover:underline">Отмена</a>
</div>
</form>
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,74 @@
<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 { routes } from '@/routes';
defineProps({
deviceTypes: { type: Array, required: true },
});
const isOwner = computed(() => usePage().props.auth.user?.role === 'owner');
const categoryLabel = { sensor: 'Датчик', actuator: 'Исполнитель' };
</script>
<template>
<AppLayout>
<template #header>
<div class="flex justify-between items-center">
<div>
<Link :href="routes.devices.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">Типы устройств</h2>
</div>
<Link
v-if="isOwner"
:href="routes.deviceTypes.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="deviceTypes.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="type in deviceTypes"
:key="type.id"
class="flex flex-col gap-4 rounded-xl border border-gray-200 bg-white p-5"
>
<div class="flex items-center gap-3">
<DeviceTypeIcon :category="type.category" />
<div>
<div class="font-medium text-gray-900">{{ type.code }}</div>
<div class="text-xs text-gray-500">{{ categoryLabel[type.category] ?? type.category }}</div>
</div>
</div>
<div class="flex flex-wrap gap-1.5">
<span
v-for="capability in type.capabilities"
:key="capability"
class="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600"
>
{{ capability }}
</span>
<span v-if="type.capabilities.length === 0" class="text-xs text-gray-400">Без возможностей</span>
</div>
<div class="border-t border-gray-100 pt-3 text-xs text-gray-400">
{{ type.devices_count }} {{ type.devices_count === 1 ? 'устройство' : 'устройств' }}
</div>
</div>
</div>
</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,76 @@
<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>
<div class="flex items-center gap-3">
<Link :href="routes.deviceTypes.index" class="text-sm text-gray-500 hover:text-gray-700">Типы устройств</Link>
<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>
</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,66 @@
<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 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" />
</tr>
</thead>
<tbody>
<tr v-if="zones.length === 0">
<td colspan="4" class="py-4 text-gray-500">Зон пока нет.</td>
</tr>
<tr v-for="zone in zones" :key="zone.id" 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">
<Link v-if="isOwner" :href="routes.zones.edit(zone.id)" class="text-indigo-600 hover:underline">Изменить</Link>
<button v-if="isOwner" type="button" class="text-red-600 hover:underline" @click="destroy(zone)">Удалить</button>
</td>
</tr>
</tbody>
</table>
</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';
window.Alpine = Alpine;
Alpine.start();
return pages[`./Pages/${name}.vue`];
},
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el);
},
});
+49
View File
@@ -0,0 +1,49 @@
// 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',
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`,
},
deviceTypes: {
index: '/device-types',
create: '/device-types/create',
store: '/device-types',
},
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>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<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'])
@inertiaHead
</head>
<body class="font-sans antialiased">
<div class="min-h-screen bg-gray-100">
@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>
@inertia
</body>
</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>
+4 -1
View File
@@ -2,6 +2,7 @@
use App\Http\Controllers\AutomationRuleController;
use App\Http\Controllers\DeviceController;
use App\Http\Controllers\DeviceTypeController;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\ZoneController;
use App\Models\AutomationRule;
@@ -9,6 +10,7 @@ use App\Models\Device;
use App\Models\Zone;
use App\Services\DeviceShadow;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
Route::get('/', function () {
return view('welcome');
@@ -20,7 +22,7 @@ Route::get('/dashboard', function (DeviceShadow $shadow) {
->filter(fn (string $status) => $status === 'online')
->count();
return view('dashboard', [
return Inertia::render('Dashboard', [
'zonesCount' => Zone::count(),
'devicesCount' => $devices->count(),
'onlineDevicesCount' => $onlineCount,
@@ -31,6 +33,7 @@ Route::get('/dashboard', function (DeviceShadow $shadow) {
Route::middleware(['auth', 'verified'])->group(function () {
Route::resource('zones', ZoneController::class)->except('show');
Route::resource('devices', DeviceController::class);
Route::resource('device-types', DeviceTypeController::class)->only(['index', 'create', 'store']);
Route::resource('automation-rules', AutomationRuleController::class)
->except('show')
->parameters(['automation-rules' => 'automation_rule']);
+1
View File
@@ -7,6 +7,7 @@ export default {
'./vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php',
'./storage/framework/views/*.php',
'./resources/views/**/*.blade.php',
'./resources/js/**/*.vue',
],
theme: {
@@ -12,6 +12,7 @@ use App\Services\ClickHouseClient;
use App\Services\DeviceControlClient;
use App\Services\DeviceShadow;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class DeviceControllerTest extends TestCase
@@ -123,7 +124,10 @@ class DeviceControllerTest extends TestCase
$response = $this->actingAs($owner)->get(route('devices.show', $device));
$response->assertOk();
$response->assertSee('online');
$response->assertSee('24.5');
$response->assertInertia(fn (Assert $page) => $page
->component('Devices/Show')
->where('device.status', 'online')
->where('telemetry.0.value', 24.5)
);
}
}
@@ -0,0 +1,76 @@
<?php
namespace Tests\Feature;
use App\Enums\UserRole;
use App\Models\DeviceType;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class DeviceTypeControllerTest extends TestCase
{
use RefreshDatabase;
public function test_owner_can_create_device_type(): void
{
$owner = User::factory()->create(['role' => UserRole::Owner]);
$response = $this->actingAs($owner)->post(route('device-types.store'), [
'code' => 'smart_socket',
'category' => 'actuator',
'capabilities' => ['turn_on', 'turn_off'],
]);
$response->assertRedirect(route('device-types.index'));
$this->assertDatabaseHas('device_types', [
'code' => 'smart_socket',
'category' => 'actuator',
]);
$this->assertSame(
['turn_on', 'turn_off'],
DeviceType::where('code', 'smart_socket')->first()->capabilities,
);
}
public function test_viewer_cannot_create_device_type(): void
{
$viewer = User::factory()->create(['role' => UserRole::Viewer]);
$response = $this->actingAs($viewer)->post(route('device-types.store'), [
'code' => 'smart_socket',
'category' => 'actuator',
]);
$response->assertForbidden();
$this->assertDatabaseMissing('device_types', ['code' => 'smart_socket']);
}
public function test_code_must_be_unique(): void
{
$owner = User::factory()->create(['role' => UserRole::Owner]);
DeviceType::create(['code' => 'fan', 'category' => 'actuator', 'capabilities' => []]);
$response = $this->actingAs($owner)->post(route('device-types.store'), [
'code' => 'fan',
'category' => 'actuator',
]);
$response->assertSessionHasErrors('code');
}
public function test_viewer_can_view_device_types_index(): void
{
$viewer = User::factory()->create(['role' => UserRole::Viewer]);
DeviceType::create(['code' => 'fan', 'category' => 'actuator', 'capabilities' => ['turn_on']]);
$response = $this->actingAs($viewer)->get(route('device-types.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('DeviceTypes/Index')
->where('deviceTypes.0.code', 'fan')
);
}
}
@@ -6,6 +6,7 @@ use App\Enums\UserRole;
use App\Models\User;
use App\Models\Zone;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class ZoneControllerTest extends TestCase
@@ -45,7 +46,10 @@ class ZoneControllerTest extends TestCase
$response = $this->actingAs($viewer)->get(route('zones.index'));
$response->assertOk();
$response->assertSee('Гроубокс');
$response->assertInertia(fn (Assert $page) => $page
->component('Zones/Index')
->where('zones.0.name', 'Гроубокс')
);
}
public function test_viewer_cannot_delete_zone(): void
+15
View File
@@ -1,11 +1,26 @@
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./resources/js', import.meta.url)),
},
},
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
}),
vue({
template: {
transformAssetUrls: {
base: null,
includeAbsolute: false,
},
},
}),
],
});