Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7aa0ba88e | ||
|
|
be1f238f89 |
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,11 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ZoneRequest;
|
||||
use App\Http\Resources\DeviceResource;
|
||||
use App\Http\Resources\ZoneResource;
|
||||
use App\Models\Zone;
|
||||
use App\Services\DeviceShadow;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ZoneController extends Controller
|
||||
{
|
||||
@@ -13,14 +17,30 @@ 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 show(Zone $zone, DeviceShadow $shadow)
|
||||
{
|
||||
$this->authorize('view', $zone);
|
||||
|
||||
$devices = $zone->devices()->with('deviceType')->orderBy('name')->get();
|
||||
$statuses = $shadow->statuses($devices->pluck('external_id')->all());
|
||||
$devices->each(fn ($d) => $d->live_status = $statuses[$d->external_id] ?? 'unknown');
|
||||
|
||||
return Inertia::render('Zones/Show', [
|
||||
'zone' => (new ZoneResource($zone))->resolve(),
|
||||
'devices' => DeviceResource::collection($devices)->resolve(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$this->authorize('create', Zone::class);
|
||||
|
||||
return view('zones.create');
|
||||
return Inertia::render('Zones/Create');
|
||||
}
|
||||
|
||||
public function store(ZoneRequest $request)
|
||||
@@ -34,7 +54,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'),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+73
-1
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Generated
+317
-73
@@ -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",
|
||||
|
||||
@@ -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,20 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
category: { type: String, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full"
|
||||
:class="category === 'sensor' ? 'bg-sky-50 text-sky-600' : 'bg-amber-50 text-amber-600'"
|
||||
>
|
||||
<svg v-if="category === 'sensor'" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v10.5a3.5 3.5 0 1 0 3 0" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.5 6h5" />
|
||||
</svg>
|
||||
<svg v-else class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M13 2 3 13.5h6.5L10.5 22 21 9.5h-6.5L13 2Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
|
||||
defineProps({
|
||||
href: { type: String, required: true },
|
||||
active: { type: Boolean, default: false },
|
||||
mobile: { type: Boolean, default: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Link
|
||||
:href="href"
|
||||
:class="[
|
||||
mobile
|
||||
? 'block w-full ps-3 pe-4 py-2 border-l-4 text-start text-base font-medium transition duration-150 ease-in-out focus:outline-none'
|
||||
: 'inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium leading-5 transition duration-150 ease-in-out focus:outline-none',
|
||||
active
|
||||
? mobile
|
||||
? 'border-indigo-400 text-indigo-700 bg-indigo-50 focus:text-indigo-800 focus:bg-indigo-100 focus:border-indigo-700'
|
||||
: 'border-indigo-400 text-gray-900 focus:border-indigo-700'
|
||||
: mobile
|
||||
? 'border-transparent text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:text-gray-700 focus:border-gray-300',
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</Link>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
status: { type: String, required: true },
|
||||
});
|
||||
|
||||
const classes = {
|
||||
online: 'bg-green-100 text-green-800',
|
||||
offline: 'bg-gray-100 text-gray-600',
|
||||
unknown: 'bg-yellow-100 text-yellow-800',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-block px-2 py-0.5 rounded text-xs" :class="classes[status] ?? classes.unknown">{{ status }}</span>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
status: { type: String, required: true },
|
||||
});
|
||||
|
||||
const dotColor = {
|
||||
online: 'bg-green-500',
|
||||
offline: 'bg-gray-300',
|
||||
unknown: 'bg-yellow-400',
|
||||
};
|
||||
|
||||
const label = {
|
||||
online: 'Онлайн',
|
||||
offline: 'Офлайн',
|
||||
unknown: 'Неизвестно',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span class="relative flex h-2.5 w-2.5">
|
||||
<span
|
||||
v-if="status === 'online'"
|
||||
class="absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"
|
||||
:class="dotColor[status]"
|
||||
/>
|
||||
<span class="relative inline-flex h-2.5 w-2.5 rounded-full" :class="dotColor[status] ?? dotColor.unknown" />
|
||||
</span>
|
||||
<span class="text-xs font-medium text-gray-500">{{ label[status] ?? status }}</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { Link, router, usePage } from '@inertiajs/vue3';
|
||||
import ApplicationLogo from '@/Components/ApplicationLogo.vue';
|
||||
import NavLink from '@/Components/NavLink.vue';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
const page = usePage();
|
||||
const user = computed(() => page.props.auth.user);
|
||||
const flash = computed(() => page.props.flash);
|
||||
|
||||
const currentPath = computed(() => new URL(page.url, window.location.origin).pathname);
|
||||
const isActive = (prefix) => currentPath.value === prefix || currentPath.value.startsWith(`${prefix}/`);
|
||||
|
||||
const showingMobileMenu = ref(false);
|
||||
const showingUserDropdown = ref(false);
|
||||
|
||||
function logout() {
|
||||
router.post(routes.logout);
|
||||
}
|
||||
|
||||
const navLinks = [
|
||||
{ href: routes.dashboard, label: 'Dashboard', prefix: routes.dashboard },
|
||||
{ href: routes.zones.index, label: 'Зоны', prefix: routes.zones.index },
|
||||
{ href: routes.devices.index, label: 'Устройства', prefix: routes.devices.index },
|
||||
{ href: routes.automationRules.index, label: 'Правила', prefix: routes.automationRules.index },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<nav class="bg-white border-b border-gray-100">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex">
|
||||
<div class="shrink-0 flex items-center">
|
||||
<Link :href="routes.dashboard">
|
||||
<ApplicationLogo class="block h-9 w-auto fill-current text-gray-800" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
|
||||
<NavLink
|
||||
v-for="link in navLinks"
|
||||
:key="link.href"
|
||||
:href="link.href"
|
||||
:active="isActive(link.prefix)"
|
||||
>
|
||||
{{ link.label }}
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:flex sm:items-center sm:ms-6">
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150"
|
||||
@click="showingUserDropdown = !showingUserDropdown"
|
||||
>
|
||||
<div>{{ user?.name }}</div>
|
||||
<svg class="ms-1 fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-show="showingUserDropdown"
|
||||
class="absolute end-0 z-50 mt-2 w-48 rounded-md shadow-lg"
|
||||
@click="showingUserDropdown = false"
|
||||
>
|
||||
<div class="rounded-md ring-1 ring-black ring-opacity-5 py-1 bg-white">
|
||||
<Link :href="routes.profile.edit" class="block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 hover:bg-gray-100">
|
||||
Профиль
|
||||
</Link>
|
||||
<button type="button" class="block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 hover:bg-gray-100" @click="logout">
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="-me-2 flex items-center sm:hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none transition duration-150 ease-in-out"
|
||||
@click="showingMobileMenu = !showingMobileMenu"
|
||||
>
|
||||
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
|
||||
<path v-if="!showingMobileMenu" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="showingMobileMenu" class="sm:hidden">
|
||||
<div class="pt-2 pb-3 space-y-1">
|
||||
<NavLink
|
||||
v-for="link in navLinks"
|
||||
:key="link.href"
|
||||
:href="link.href"
|
||||
:active="isActive(link.prefix)"
|
||||
mobile
|
||||
>
|
||||
{{ link.label }}
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 pb-1 border-t border-gray-200">
|
||||
<div class="px-4">
|
||||
<div class="font-medium text-base text-gray-800">{{ user?.name }}</div>
|
||||
<div class="font-medium text-sm text-gray-500">{{ user?.email }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-1">
|
||||
<NavLink :href="routes.profile.edit" mobile>Профиль</NavLink>
|
||||
<button type="button" class="block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" @click="logout">
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<header v-if="$slots.header" class="bg-white shadow">
|
||||
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div v-if="flash.status || flash.error" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-4">
|
||||
<div v-if="flash.status" class="rounded-md bg-green-50 p-4 text-sm text-green-700">{{ flash.status }}</div>
|
||||
<div v-if="flash.error" class="rounded-md bg-red-50 p-4 text-sm text-red-700">{{ flash.error }}</div>
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import Form from '@/Pages/AutomationRules/Form.vue';
|
||||
|
||||
defineProps({
|
||||
zones: { type: Array, required: true },
|
||||
devices: { type: Array, required: true },
|
||||
actionTypeOptions: { type: Array, required: true },
|
||||
sensorTypeOptions: { type: Array, required: true },
|
||||
conditionOperatorOptions: { type: Array, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Новое правило</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<Form
|
||||
:zones="zones"
|
||||
:devices="devices"
|
||||
:action-type-options="actionTypeOptions"
|
||||
:sensor-type-options="sensorTypeOptions"
|
||||
:condition-operator-options="conditionOperatorOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import Form from '@/Pages/AutomationRules/Form.vue';
|
||||
|
||||
defineProps({
|
||||
rule: { type: Object, required: true },
|
||||
zones: { type: Array, required: true },
|
||||
devices: { type: Array, required: true },
|
||||
actionTypeOptions: { type: Array, required: true },
|
||||
sensorTypeOptions: { type: Array, required: true },
|
||||
conditionOperatorOptions: { type: Array, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Изменить правило</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<Form
|
||||
:rule="rule"
|
||||
:zones="zones"
|
||||
:devices="devices"
|
||||
:action-type-options="actionTypeOptions"
|
||||
:sensor-type-options="sensorTypeOptions"
|
||||
:condition-operator-options="conditionOperatorOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,176 @@
|
||||
<script setup>
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
const props = defineProps({
|
||||
rule: { type: Object, default: null },
|
||||
zones: { type: Array, required: true },
|
||||
devices: { type: Array, required: true },
|
||||
actionTypeOptions: { type: Array, required: true },
|
||||
sensorTypeOptions: { type: Array, required: true },
|
||||
conditionOperatorOptions: { type: Array, required: true },
|
||||
});
|
||||
|
||||
const isEdit = !!props.rule;
|
||||
|
||||
const form = useForm({
|
||||
zone_id: props.rule?.zone_id ?? '',
|
||||
condition_source_device_id: props.rule?.condition_source_device_id ?? '',
|
||||
condition_sensor_type: props.rule?.condition_sensor_type ?? '',
|
||||
condition_operator: props.rule?.condition_operator ?? '',
|
||||
condition_value: props.rule?.condition_value ?? '',
|
||||
target_device_id: props.rule?.target_device_id ?? '',
|
||||
action_type: props.rule?.action_type ?? '',
|
||||
level: props.rule?.level ?? '',
|
||||
is_active: props.rule?.is_active ?? true,
|
||||
});
|
||||
|
||||
function submit() {
|
||||
if (isEdit) {
|
||||
form.put(routes.automationRules.update(props.rule.id));
|
||||
} else {
|
||||
form.post(routes.automationRules.store);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="submit">
|
||||
<div>
|
||||
<label for="zone_id" class="block font-medium text-sm text-gray-700">Зона</label>
|
||||
<select
|
||||
id="zone_id"
|
||||
v-model="form.zone_id"
|
||||
required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<option value="">— выбрать —</option>
|
||||
<option v-for="zone in zones" :key="zone.id" :value="zone.id">{{ zone.name }}</option>
|
||||
</select>
|
||||
<div v-if="form.errors.zone_id" class="mt-2 text-sm text-red-600">{{ form.errors.zone_id }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="condition_source_device_id" class="block font-medium text-sm text-gray-700">Устройство-источник показания</label>
|
||||
<select
|
||||
id="condition_source_device_id"
|
||||
v-model="form.condition_source_device_id"
|
||||
required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<option value="">— выбрать —</option>
|
||||
<option v-for="device in devices" :key="device.id" :value="device.id">
|
||||
{{ device.zone.name }} — {{ device.name }} ({{ device.device_type.code }})
|
||||
</option>
|
||||
</select>
|
||||
<div v-if="form.errors.condition_source_device_id" class="mt-2 text-sm text-red-600">{{ form.errors.condition_source_device_id }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label for="condition_sensor_type" class="block font-medium text-sm text-gray-700">Тип показания</label>
|
||||
<input
|
||||
id="condition_sensor_type"
|
||||
v-model="form.condition_sensor_type"
|
||||
list="sensor-types"
|
||||
type="text"
|
||||
required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<datalist id="sensor-types">
|
||||
<option v-for="option in sensorTypeOptions" :key="option" :value="option" />
|
||||
</datalist>
|
||||
<div v-if="form.errors.condition_sensor_type" class="mt-2 text-sm text-red-600">{{ form.errors.condition_sensor_type }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="condition_operator" class="block font-medium text-sm text-gray-700">Оператор</label>
|
||||
<select
|
||||
id="condition_operator"
|
||||
v-model="form.condition_operator"
|
||||
required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<option value="">— выбрать —</option>
|
||||
<option v-for="op in conditionOperatorOptions" :key="op" :value="op">{{ op }}</option>
|
||||
</select>
|
||||
<div v-if="form.errors.condition_operator" class="mt-2 text-sm text-red-600">{{ form.errors.condition_operator }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="condition_value" class="block font-medium text-sm text-gray-700">Порог</label>
|
||||
<input
|
||||
id="condition_value"
|
||||
v-model="form.condition_value"
|
||||
type="number"
|
||||
step="any"
|
||||
required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<div v-if="form.errors.condition_value" class="mt-2 text-sm text-red-600">{{ form.errors.condition_value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="target_device_id" class="block font-medium text-sm text-gray-700">Устройство-исполнитель</label>
|
||||
<select
|
||||
id="target_device_id"
|
||||
v-model="form.target_device_id"
|
||||
required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<option value="">— выбрать —</option>
|
||||
<option v-for="device in devices" :key="device.id" :value="device.id">
|
||||
{{ device.zone.name }} — {{ device.name }} ({{ device.device_type.code }})
|
||||
</option>
|
||||
</select>
|
||||
<div v-if="form.errors.target_device_id" class="mt-2 text-sm text-red-600">{{ form.errors.target_device_id }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="action_type" class="block font-medium text-sm text-gray-700">Действие</label>
|
||||
<select
|
||||
id="action_type"
|
||||
v-model="form.action_type"
|
||||
required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<option value="">— выбрать —</option>
|
||||
<option v-for="option in actionTypeOptions" :key="option" :value="option">{{ option }}</option>
|
||||
</select>
|
||||
<div v-if="form.errors.action_type" class="mt-2 text-sm text-red-600">{{ form.errors.action_type }}</div>
|
||||
</div>
|
||||
|
||||
<div v-show="form.action_type === 'set_level'">
|
||||
<label for="level" class="block font-medium text-sm text-gray-700">Уровень</label>
|
||||
<input
|
||||
id="level"
|
||||
v-model="form.level"
|
||||
type="number"
|
||||
step="any"
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<div v-if="form.errors.level" class="mt-2 text-sm text-red-600">{{ form.errors.level }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="inline-flex items-center">
|
||||
<input v-model="form.is_active" type="checkbox" class="rounded border-gray-300 text-indigo-600 shadow-sm">
|
||||
<span class="ms-2 text-sm text-gray-600">Правило активно</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<a :href="routes.automationRules.index" class="text-sm text-gray-600 hover:underline">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { Link, router, usePage } from '@inertiajs/vue3';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
defineProps({
|
||||
rules: { type: Array, required: true },
|
||||
});
|
||||
|
||||
const isOwner = computed(() => usePage().props.auth.user?.role === 'owner');
|
||||
|
||||
function destroy(rule) {
|
||||
if (confirm('Удалить правило?')) {
|
||||
router.delete(routes.automationRules.destroy(rule.id));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Правила автоматизации</h2>
|
||||
<Link
|
||||
v-if="isOwner"
|
||||
:href="routes.automationRules.create"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700"
|
||||
>
|
||||
Добавить правило
|
||||
</Link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b">
|
||||
<th class="py-2">Зона</th>
|
||||
<th class="py-2">Условие</th>
|
||||
<th class="py-2">Действие</th>
|
||||
<th class="py-2">Активно</th>
|
||||
<th class="py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="rules.length === 0">
|
||||
<td colspan="5" class="py-4 text-gray-500">Правил пока нет.</td>
|
||||
</tr>
|
||||
<tr v-for="rule in rules" :key="rule.id" class="border-b">
|
||||
<td class="py-2">{{ rule.zone.name }}</td>
|
||||
<td class="py-2">
|
||||
{{ rule.condition_source_device.name }}:
|
||||
{{ rule.condition_sensor_type }}
|
||||
{{ rule.condition_operator }}
|
||||
{{ rule.condition_value }}
|
||||
</td>
|
||||
<td class="py-2">
|
||||
{{ rule.target_device.name }}: {{ rule.action_type }}
|
||||
<template v-if="Object.keys(rule.action_params ?? {}).length">
|
||||
({{ JSON.stringify(rule.action_params) }})
|
||||
</template>
|
||||
</td>
|
||||
<td class="py-2">{{ rule.is_active ? 'да' : 'нет' }}</td>
|
||||
<td class="py-2 text-right space-x-2">
|
||||
<Link v-if="isOwner" :href="routes.automationRules.edit(rule.id)" class="text-indigo-600 hover:underline">Изменить</Link>
|
||||
<button v-if="isOwner" type="button" class="text-red-600 hover:underline" @click="destroy(rule)">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
defineProps({
|
||||
zonesCount: Number,
|
||||
devicesCount: Number,
|
||||
onlineDevicesCount: Number,
|
||||
activeRulesCount: Number,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Dashboard</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 grid grid-cols-1 sm:grid-cols-4 gap-6">
|
||||
<Link :href="routes.zones.index" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
|
||||
<div class="text-sm text-gray-500">Зоны</div>
|
||||
<div class="text-3xl font-semibold text-gray-900">{{ zonesCount }}</div>
|
||||
</Link>
|
||||
<Link :href="routes.devices.index" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
|
||||
<div class="text-sm text-gray-500">Устройства</div>
|
||||
<div class="text-3xl font-semibold text-gray-900">{{ devicesCount }}</div>
|
||||
</Link>
|
||||
<Link :href="routes.devices.index" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
|
||||
<div class="text-sm text-gray-500">Онлайн</div>
|
||||
<div class="text-3xl font-semibold text-green-600">{{ onlineDevicesCount }}</div>
|
||||
</Link>
|
||||
<Link :href="routes.automationRules.index" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
|
||||
<div class="text-sm text-gray-500">Активных правил</div>
|
||||
<div class="text-3xl font-semibold text-gray-900">{{ activeRulesCount }}</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import Form from '@/Pages/Devices/Form.vue';
|
||||
|
||||
defineProps({
|
||||
zones: { type: Array, required: true },
|
||||
deviceTypes: { type: Array, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Новое устройство</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<Form :zones="zones" :device-types="deviceTypes" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import Form from '@/Pages/Devices/Form.vue';
|
||||
|
||||
defineProps({
|
||||
device: { type: Object, required: true },
|
||||
zones: { type: Array, required: true },
|
||||
deviceTypes: { type: Array, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Изменить устройство</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<Form :device="device" :zones="zones" :device-types="deviceTypes" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup>
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
const props = defineProps({
|
||||
device: { type: Object, default: null },
|
||||
zones: { type: Array, required: true },
|
||||
deviceTypes: { type: Array, required: true },
|
||||
});
|
||||
|
||||
const isEdit = !!props.device;
|
||||
|
||||
const form = useForm({
|
||||
name: props.device?.name ?? '',
|
||||
zone_id: props.device?.zone_id ?? '',
|
||||
device_type_id: props.device?.device_type_id ?? '',
|
||||
external_id: props.device?.external_id ?? '',
|
||||
protocol: props.device?.protocol ?? 'mqtt',
|
||||
});
|
||||
|
||||
function submit() {
|
||||
if (isEdit) {
|
||||
form.put(routes.devices.update(props.device.id));
|
||||
} else {
|
||||
form.post(routes.devices.store);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="submit">
|
||||
<div>
|
||||
<label for="name" class="block font-medium text-sm text-gray-700">Название</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
autofocus
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<div v-if="form.errors.name" class="mt-2 text-sm text-red-600">{{ form.errors.name }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="zone_id" class="block font-medium text-sm text-gray-700">Зона</label>
|
||||
<select
|
||||
id="zone_id"
|
||||
v-model="form.zone_id"
|
||||
required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<option value="">— выбрать —</option>
|
||||
<option v-for="zone in zones" :key="zone.id" :value="zone.id">{{ zone.name }}</option>
|
||||
</select>
|
||||
<div v-if="form.errors.zone_id" class="mt-2 text-sm text-red-600">{{ form.errors.zone_id }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="device_type_id" class="block font-medium text-sm text-gray-700">Тип устройства</label>
|
||||
<select
|
||||
id="device_type_id"
|
||||
v-model="form.device_type_id"
|
||||
required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<option value="">— выбрать —</option>
|
||||
<option v-for="type in deviceTypes" :key="type.id" :value="type.id">{{ type.code }} ({{ type.category }})</option>
|
||||
</select>
|
||||
<div v-if="form.errors.device_type_id" class="mt-2 text-sm text-red-600">{{ form.errors.device_type_id }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="external_id" class="block font-medium text-sm text-gray-700">External ID (идентификатор физического устройства)</label>
|
||||
<input
|
||||
id="external_id"
|
||||
v-model="form.external_id"
|
||||
type="text"
|
||||
required
|
||||
class="mt-1 block w-full font-mono border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<div v-if="form.errors.external_id" class="mt-2 text-sm text-red-600">{{ form.errors.external_id }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="protocol" class="block font-medium text-sm text-gray-700">Протокол</label>
|
||||
<input
|
||||
id="protocol"
|
||||
v-model="form.protocol"
|
||||
type="text"
|
||||
required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<div v-if="form.errors.protocol" class="mt-2 text-sm text-red-600">{{ form.errors.protocol }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<a :href="routes.devices.index" class="text-sm text-gray-600 hover:underline">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { Link, router, usePage } from '@inertiajs/vue3';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import StatusBadge from '@/Components/StatusBadge.vue';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
defineProps({
|
||||
devices: { type: Array, required: true },
|
||||
});
|
||||
|
||||
const isOwner = computed(() => usePage().props.auth.user?.role === 'owner');
|
||||
|
||||
function destroy(device) {
|
||||
if (confirm('Удалить устройство?')) {
|
||||
router.delete(routes.devices.destroy(device.id));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Устройства</h2>
|
||||
<Link
|
||||
v-if="isOwner"
|
||||
:href="routes.devices.create"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700"
|
||||
>
|
||||
Добавить устройство
|
||||
</Link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b">
|
||||
<th class="py-2">Название</th>
|
||||
<th class="py-2">Зона</th>
|
||||
<th class="py-2">Тип</th>
|
||||
<th class="py-2">Статус</th>
|
||||
<th class="py-2">External ID</th>
|
||||
<th class="py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="devices.length === 0">
|
||||
<td colspan="6" class="py-4 text-gray-500">Устройств пока нет.</td>
|
||||
</tr>
|
||||
<tr v-for="device in devices" :key="device.id" class="border-b">
|
||||
<td class="py-2">
|
||||
<Link :href="routes.devices.show(device.id)" class="text-indigo-600 hover:underline">{{ device.name }}</Link>
|
||||
</td>
|
||||
<td class="py-2">{{ device.zone.name }}</td>
|
||||
<td class="py-2">{{ device.device_type.code }}</td>
|
||||
<td class="py-2"><StatusBadge :status="device.status" /></td>
|
||||
<td class="py-2 font-mono text-xs">{{ device.external_id }}</td>
|
||||
<td class="py-2 text-right space-x-2">
|
||||
<Link v-if="isOwner" :href="routes.devices.edit(device.id)" class="text-indigo-600 hover:underline">Изменить</Link>
|
||||
<button v-if="isOwner" type="button" class="text-red-600 hover:underline" @click="destroy(device)">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useForm, usePage } from '@inertiajs/vue3';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import StatusBadge from '@/Components/StatusBadge.vue';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
const props = defineProps({
|
||||
device: { type: Object, required: true },
|
||||
shadow: { type: Object, required: true },
|
||||
telemetry: { type: Array, required: true },
|
||||
});
|
||||
|
||||
const isOwner = computed(() => usePage().props.auth.user?.role === 'owner');
|
||||
const capabilities = computed(() => props.device.device_type.capabilities ?? []);
|
||||
const hasControls = computed(() => capabilities.value.some((c) => ['turn_on', 'turn_off', 'set_level'].includes(c)));
|
||||
|
||||
const lastSeenLabel = computed(() => (
|
||||
props.shadow.last_seen ? new Date(props.shadow.last_seen).toLocaleString('ru-RU') : 'нет данных'
|
||||
));
|
||||
|
||||
const levelForm = useForm({ level: '' });
|
||||
|
||||
function turnOn() {
|
||||
useForm({}).post(routes.devices.turnOn(props.device.id));
|
||||
}
|
||||
|
||||
function turnOff() {
|
||||
useForm({}).post(routes.devices.turnOff(props.device.id));
|
||||
}
|
||||
|
||||
function setLevel() {
|
||||
levelForm.post(routes.devices.setLevel(props.device.id));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ device.name }}</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<h3 class="font-semibold mb-4">Живое состояние (Redis device shadow)</h3>
|
||||
<dl class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<dt class="text-gray-500">Статус</dt>
|
||||
<dd class="mt-1"><StatusBadge :status="device.status" /></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500">Последняя активность</dt>
|
||||
<dd>{{ lastSeenLabel }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500">Желаемое состояние (desired)</dt>
|
||||
<dd class="font-mono text-xs">{{ JSON.stringify(shadow.desired_state) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500">Подтверждённое состояние (reported)</dt>
|
||||
<dd class="font-mono text-xs">{{ JSON.stringify(shadow.reported_state) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div v-if="isOwner && hasControls" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<h3 class="font-semibold mb-4">Ручное управление</h3>
|
||||
<div class="flex items-center gap-4 flex-wrap">
|
||||
<button
|
||||
v-if="capabilities.includes('turn_on')"
|
||||
type="button"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700"
|
||||
@click="turnOn"
|
||||
>
|
||||
Включить
|
||||
</button>
|
||||
<button
|
||||
v-if="capabilities.includes('turn_off')"
|
||||
type="button"
|
||||
class="inline-flex items-center px-4 py-2 bg-white border border-gray-300 rounded-md font-semibold text-xs text-gray-700 uppercase tracking-widest hover:bg-gray-50"
|
||||
@click="turnOff"
|
||||
>
|
||||
Выключить
|
||||
</button>
|
||||
<form v-if="capabilities.includes('set_level')" class="flex items-center gap-2" @submit.prevent="setLevel">
|
||||
<input
|
||||
v-model="levelForm.level"
|
||||
type="number"
|
||||
step="any"
|
||||
required
|
||||
placeholder="уровень"
|
||||
class="border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm text-sm"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700"
|
||||
>
|
||||
Установить уровень
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="device.device_type.category === 'sensor'" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<h3 class="font-semibold mb-4">Последние показания (ClickHouse)</h3>
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b">
|
||||
<th class="py-2">Тип</th>
|
||||
<th class="py-2">Значение</th>
|
||||
<th class="py-2">Время</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="telemetry.length === 0">
|
||||
<td colspan="3" class="py-4 text-gray-500">Показаний пока нет.</td>
|
||||
</tr>
|
||||
<tr v-for="(row, i) in telemetry" :key="i" class="border-b">
|
||||
<td class="py-2">{{ row.sensor_type }}</td>
|
||||
<td class="py-2">{{ row.value }}</td>
|
||||
<td class="py-2">{{ row.recorded_at }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<a :href="routes.devices.index" class="text-sm text-gray-600 hover:underline">← Назад к устройствам</a>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import UpdateProfileInformationForm from '@/Pages/Profile/Partials/UpdateProfileInformationForm.vue';
|
||||
import UpdatePasswordForm from '@/Pages/Profile/Partials/UpdatePasswordForm.vue';
|
||||
import DeleteUserForm from '@/Pages/Profile/Partials/DeleteUserForm.vue';
|
||||
|
||||
defineProps({
|
||||
user: { type: Object, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Профиль</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
|
||||
<div class="max-w-xl">
|
||||
<UpdateProfileInformationForm :user="user" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
|
||||
<div class="max-w-xl">
|
||||
<UpdatePasswordForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
|
||||
<div class="max-w-xl">
|
||||
<DeleteUserForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
const confirmingDeletion = ref(false);
|
||||
|
||||
const form = useForm({ password: '' });
|
||||
|
||||
function confirmDeletion() {
|
||||
confirmingDeletion.value = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
confirmingDeletion.value = false;
|
||||
form.reset();
|
||||
form.clearErrors();
|
||||
}
|
||||
|
||||
function submit() {
|
||||
form.delete(routes.profile.destroy, {
|
||||
preserveScroll: true,
|
||||
errorBag: 'userDeletion',
|
||||
onSuccess: () => closeModal(),
|
||||
onError: () => {},
|
||||
onFinish: () => form.reset('password'),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<header>
|
||||
<h2 class="text-lg font-medium text-gray-900">Удаление аккаунта</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
После удаления аккаунта все связанные данные будут безвозвратно удалены.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-500"
|
||||
@click="confirmDeletion"
|
||||
>
|
||||
Удалить аккаунт
|
||||
</button>
|
||||
|
||||
<div v-if="confirmingDeletion" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full p-6">
|
||||
<form @submit.prevent="submit">
|
||||
<h2 class="text-lg font-medium text-gray-900">Точно удалить аккаунт?</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
Это необратимо. Введите пароль, чтобы подтвердить удаление.
|
||||
</p>
|
||||
|
||||
<div class="mt-6">
|
||||
<label for="delete-password" class="sr-only">Пароль</label>
|
||||
<input
|
||||
id="delete-password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="Пароль"
|
||||
class="mt-1 block w-3/4 border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<div v-if="form.errors.password" class="mt-2 text-sm text-red-600">{{ form.errors.password }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center px-4 py-2 bg-white border border-gray-300 rounded-md font-semibold text-xs text-gray-700 uppercase tracking-widest hover:bg-gray-50"
|
||||
@click="closeModal"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
class="inline-flex items-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-500 disabled:opacity-50"
|
||||
>
|
||||
Удалить аккаунт
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useForm, usePage } from '@inertiajs/vue3';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
const form = useForm({
|
||||
current_password: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
});
|
||||
|
||||
const flashStatus = computed(() => usePage().props.flash.status);
|
||||
|
||||
function submit() {
|
||||
form.put(routes.password.update, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => form.reset(),
|
||||
onError: () => {
|
||||
if (form.errors.password) form.reset('password', 'password_confirmation');
|
||||
if (form.errors.current_password) form.reset('current_password');
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<header>
|
||||
<h2 class="text-lg font-medium text-gray-900">Смена пароля</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">Длинный случайный пароль надёжнее короткого запоминающегося.</p>
|
||||
</header>
|
||||
|
||||
<form class="mt-6 space-y-6" @submit.prevent="submit">
|
||||
<div>
|
||||
<label for="current_password" class="block font-medium text-sm text-gray-700">Текущий пароль</label>
|
||||
<input
|
||||
id="current_password"
|
||||
v-model="form.current_password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<div v-if="form.errors.current_password" class="mt-2 text-sm text-red-600">{{ form.errors.current_password }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block font-medium text-sm text-gray-700">Новый пароль</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<div v-if="form.errors.password" class="mt-2 text-sm text-red-600">{{ form.errors.password }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password_confirmation" class="block font-medium text-sm text-gray-700">Подтверждение пароля</label>
|
||||
<input
|
||||
id="password_confirmation"
|
||||
v-model="form.password_confirmation"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<div v-if="form.errors.password_confirmation" class="mt-2 text-sm text-red-600">{{ form.errors.password_confirmation }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<p v-if="flashStatus === 'password-updated'" class="text-sm text-gray-600">Сохранено.</p>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useForm, usePage } from '@inertiajs/vue3';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
const props = defineProps({
|
||||
user: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
name: props.user.name,
|
||||
email: props.user.email,
|
||||
});
|
||||
|
||||
const flashStatus = computed(() => usePage().props.flash.status);
|
||||
|
||||
function submit() {
|
||||
form.patch(routes.profile.update);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<header>
|
||||
<h2 class="text-lg font-medium text-gray-900">Данные профиля</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">Имя и email аккаунта.</p>
|
||||
</header>
|
||||
|
||||
<form class="mt-6 space-y-6" @submit.prevent="submit">
|
||||
<div>
|
||||
<label for="name" class="block font-medium text-sm text-gray-700">Имя</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="name"
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<div v-if="form.errors.name" class="mt-2 text-sm text-red-600">{{ form.errors.name }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block font-medium text-sm text-gray-700">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="username"
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<div v-if="form.errors.email" class="mt-2 text-sm text-red-600">{{ form.errors.email }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<p v-if="flashStatus === 'profile-updated'" class="text-sm text-gray-600">Сохранено.</p>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import Form from '@/Pages/Zones/Form.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Новая зона</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<Form />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import Form from '@/Pages/Zones/Form.vue';
|
||||
|
||||
defineProps({
|
||||
zone: { type: Object, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Изменить зону</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<Form :zone="zone" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup>
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
const props = defineProps({
|
||||
zone: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const isEdit = !!props.zone;
|
||||
|
||||
const form = useForm({
|
||||
name: props.zone?.name ?? '',
|
||||
description: props.zone?.description ?? '',
|
||||
});
|
||||
|
||||
function submit() {
|
||||
if (isEdit) {
|
||||
form.put(routes.zones.update(props.zone.id));
|
||||
} else {
|
||||
form.post(routes.zones.store);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="submit">
|
||||
<div>
|
||||
<label for="name" class="block font-medium text-sm text-gray-700">Название</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
autofocus
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<div v-if="form.errors.name" class="mt-2 text-sm text-red-600">{{ form.errors.name }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="description" class="block font-medium text-sm text-gray-700">Описание</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
rows="3"
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
/>
|
||||
<div v-if="form.errors.description" class="mt-2 text-sm text-red-600">{{ form.errors.description }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<a :href="routes.zones.index" class="text-sm text-gray-600 hover:underline">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { Link, router, usePage } from '@inertiajs/vue3';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
defineProps({
|
||||
zones: { type: Array, required: true },
|
||||
});
|
||||
|
||||
const isOwner = computed(() => usePage().props.auth.user?.role === 'owner');
|
||||
|
||||
function destroy(zone) {
|
||||
if (confirm('Удалить зону?')) {
|
||||
router.delete(routes.zones.destroy(zone.id));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Зоны</h2>
|
||||
<Link
|
||||
v-if="isOwner"
|
||||
:href="routes.zones.create"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700"
|
||||
>
|
||||
Добавить зону
|
||||
</Link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div v-if="zones.length === 0" class="bg-white rounded-xl border border-dashed border-gray-300 p-10 text-center text-sm text-gray-500">
|
||||
Зон пока нет.
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="zone in zones"
|
||||
:key="zone.id"
|
||||
class="group relative flex flex-col gap-3 rounded-xl border border-gray-200 bg-white p-5 transition hover:border-indigo-200 hover:shadow-md"
|
||||
>
|
||||
<Link :href="routes.zones.show(zone.id)" class="absolute inset-0" :aria-label="zone.name" />
|
||||
|
||||
<div class="flex items-start justify-between">
|
||||
<h3 class="font-medium text-gray-900 group-hover:text-indigo-600">{{ zone.name }}</h3>
|
||||
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600">
|
||||
{{ zone.devices_count }} {{ zone.devices_count === 1 ? 'устройство' : 'устройств' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500 min-h-5">{{ zone.description }}</p>
|
||||
|
||||
<div v-if="isOwner" class="relative z-10 mt-2 flex items-center gap-3 border-t border-gray-100 pt-3 text-sm">
|
||||
<Link :href="routes.zones.edit(zone.id)" class="text-indigo-600 hover:underline">Изменить</Link>
|
||||
<button type="button" class="text-red-600 hover:underline" @click="destroy(zone)">Удалить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import DeviceTypeIcon from '@/Components/DeviceTypeIcon.vue';
|
||||
import StatusDot from '@/Components/StatusDot.vue';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
const props = defineProps({
|
||||
zone: { type: Object, required: true },
|
||||
devices: { type: Array, required: true },
|
||||
});
|
||||
|
||||
const isOwner = computed(() => usePage().props.auth.user?.role === 'owner');
|
||||
|
||||
const onlineCount = computed(() => props.devices.filter((d) => d.status === 'online').length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Link :href="routes.zones.index" class="text-sm text-gray-500 hover:text-gray-700">← Зоны</Link>
|
||||
<h2 class="mt-1 font-semibold text-xl text-gray-800 leading-tight">{{ zone.name }}</h2>
|
||||
</div>
|
||||
<Link
|
||||
v-if="isOwner"
|
||||
:href="routes.zones.edit(zone.id)"
|
||||
class="inline-flex items-center px-4 py-2 bg-white border border-gray-300 rounded-md font-semibold text-xs text-gray-700 uppercase tracking-widest hover:bg-gray-50"
|
||||
>
|
||||
Изменить зону
|
||||
</Link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
<div v-if="zone.description" class="text-sm text-gray-600">{{ zone.description }}</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span class="font-medium text-gray-700">{{ devices.length }}</span> устройств,
|
||||
<span class="font-medium text-green-600">{{ onlineCount }}</span> онлайн
|
||||
</div>
|
||||
|
||||
<div v-if="devices.length === 0" class="bg-white rounded-xl border border-dashed border-gray-300 p-10 text-center text-sm text-gray-500">
|
||||
В этой зоне пока нет устройств.
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Link
|
||||
v-for="device in devices"
|
||||
:key="device.id"
|
||||
:href="routes.devices.show(device.id)"
|
||||
class="group flex flex-col gap-4 rounded-xl border border-gray-200 bg-white p-5 transition hover:border-indigo-200 hover:shadow-md"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<DeviceTypeIcon :category="device.device_type.category" />
|
||||
<div>
|
||||
<div class="font-medium text-gray-900 group-hover:text-indigo-600">{{ device.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ device.device_type.code }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between border-t border-gray-100 pt-3">
|
||||
<StatusDot :status="device.status" />
|
||||
<span class="font-mono text-xs text-gray-400">{{ device.external_id }}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Single source of truth for the app's URLs on the JS side — mirrors
|
||||
// routes/web.php. The route list here is small and stable enough that a
|
||||
// full Ziggy (route-name-to-JS) integration would be more machinery than
|
||||
// the app needs; if the route count grows a lot, revisit that.
|
||||
export const routes = {
|
||||
dashboard: '/dashboard',
|
||||
zones: {
|
||||
index: '/zones',
|
||||
show: (id) => `/zones/${id}`,
|
||||
create: '/zones/create',
|
||||
store: '/zones',
|
||||
edit: (id) => `/zones/${id}/edit`,
|
||||
update: (id) => `/zones/${id}`,
|
||||
destroy: (id) => `/zones/${id}`,
|
||||
},
|
||||
devices: {
|
||||
index: '/devices',
|
||||
show: (id) => `/devices/${id}`,
|
||||
create: '/devices/create',
|
||||
store: '/devices',
|
||||
edit: (id) => `/devices/${id}/edit`,
|
||||
update: (id) => `/devices/${id}`,
|
||||
destroy: (id) => `/devices/${id}`,
|
||||
turnOn: (id) => `/devices/${id}/turn-on`,
|
||||
turnOff: (id) => `/devices/${id}/turn-off`,
|
||||
setLevel: (id) => `/devices/${id}/set-level`,
|
||||
},
|
||||
automationRules: {
|
||||
index: '/automation-rules',
|
||||
create: '/automation-rules/create',
|
||||
store: '/automation-rules',
|
||||
edit: (id) => `/automation-rules/${id}/edit`,
|
||||
update: (id) => `/automation-rules/${id}`,
|
||||
destroy: (id) => `/automation-rules/${id}`,
|
||||
},
|
||||
profile: {
|
||||
edit: '/profile',
|
||||
update: '/profile',
|
||||
destroy: '/profile',
|
||||
},
|
||||
password: {
|
||||
update: '/password',
|
||||
},
|
||||
logout: '/logout',
|
||||
};
|
||||
+2
-19
@@ -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>
|
||||
-64
@@ -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>
|
||||
@@ -9,6 +9,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 +21,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,
|
||||
@@ -29,7 +30,7 @@ Route::get('/dashboard', function (DeviceShadow $shadow) {
|
||||
})->middleware(['auth', 'verified'])->name('dashboard');
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::resource('zones', ZoneController::class)->except('show');
|
||||
Route::resource('zones', ZoneController::class);
|
||||
Route::resource('devices', DeviceController::class);
|
||||
Route::resource('automation-rules', AutomationRuleController::class)
|
||||
->except('show')
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\DeviceCategory;
|
||||
use App\Enums\UserRole;
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceType;
|
||||
use App\Models\User;
|
||||
use App\Models\Zone;
|
||||
use App\Services\DeviceShadow;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ZoneControllerTest extends TestCase
|
||||
@@ -45,7 +50,39 @@ 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_zone_show_lists_its_devices_with_live_status(): void
|
||||
{
|
||||
$viewer = User::factory()->create(['role' => UserRole::Viewer]);
|
||||
$zone = Zone::forceCreate(['user_id' => $viewer->id, 'name' => 'Гроубокс']);
|
||||
$type = DeviceType::create([
|
||||
'code' => 'fan',
|
||||
'category' => DeviceCategory::Actuator,
|
||||
'capabilities' => ['turn_on'],
|
||||
]);
|
||||
Device::forceCreate([
|
||||
'user_id' => $viewer->id, 'zone_id' => $zone->id, 'device_type_id' => $type->id,
|
||||
'name' => 'Вентилятор', 'external_id' => 'fan-1', 'protocol' => 'mqtt',
|
||||
]);
|
||||
|
||||
$this->mock(DeviceShadow::class, function ($mock) {
|
||||
$mock->shouldReceive('statuses')->once()->with(['fan-1'])->andReturn(['fan-1' => 'online']);
|
||||
});
|
||||
|
||||
$response = $this->actingAs($viewer)->get(route('zones.show', $zone));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('Zones/Show')
|
||||
->where('zone.name', 'Гроубокс')
|
||||
->where('devices.0.name', 'Вентилятор')
|
||||
->where('devices.0.status', 'online')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_viewer_cannot_delete_zone(): void
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user