Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d10f95182d |
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\DeviceTypeRequest;
|
||||
use App\Http\Resources\DeviceTypeResource;
|
||||
use App\Models\DeviceType;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class DeviceTypeController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$this->authorize('viewAny', DeviceType::class);
|
||||
|
||||
$deviceTypes = DeviceType::withCount('devices')->orderBy('code')->get();
|
||||
|
||||
return Inertia::render('DeviceTypes/Index', [
|
||||
'deviceTypes' => DeviceTypeResource::collection($deviceTypes)->resolve(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$this->authorize('create', DeviceType::class);
|
||||
|
||||
return Inertia::render('DeviceTypes/Create');
|
||||
}
|
||||
|
||||
public function store(DeviceTypeRequest $request)
|
||||
{
|
||||
DeviceType::create($request->validated());
|
||||
|
||||
return redirect()->route('device-types.index')->with('status', 'Тип устройства создан.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\DeviceCategory;
|
||||
use App\Models\DeviceType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class DeviceTypeRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->can('create', DeviceType::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'code' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('device_types', 'code')],
|
||||
'category' => ['required', Rule::enum(DeviceCategory::class)],
|
||||
'capabilities' => ['array'],
|
||||
'capabilities.*' => ['string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ class DeviceTypeResource extends JsonResource
|
||||
'code' => $this->code,
|
||||
'category' => $this->category->value,
|
||||
'capabilities' => $this->capabilities,
|
||||
'devices_count' => $this->whenCounted('devices'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\DeviceType;
|
||||
use App\Models\User;
|
||||
|
||||
/** See ZonePolicy for the household-wide RBAC rationale. */
|
||||
class DeviceTypePolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function view(User $user, DeviceType $deviceType): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->isOwner();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
const form = useForm({
|
||||
code: '',
|
||||
category: '',
|
||||
capabilities: [],
|
||||
});
|
||||
|
||||
const capabilityInput = ref('');
|
||||
|
||||
function addCapability() {
|
||||
const value = capabilityInput.value.trim();
|
||||
if (value && !form.capabilities.includes(value)) {
|
||||
form.capabilities.push(value);
|
||||
}
|
||||
capabilityInput.value = '';
|
||||
}
|
||||
|
||||
function removeCapability(index) {
|
||||
form.capabilities.splice(index, 1);
|
||||
}
|
||||
|
||||
function submit() {
|
||||
form.post(routes.deviceTypes.store);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Новый тип устройства</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<form @submit.prevent="submit">
|
||||
<div>
|
||||
<label for="code" class="block font-medium text-sm text-gray-700">Код</label>
|
||||
<input
|
||||
id="code"
|
||||
v-model="form.code"
|
||||
type="text"
|
||||
required
|
||||
autofocus
|
||||
placeholder="например, smart_socket"
|
||||
class="mt-1 block w-full font-mono border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<p class="mt-1 text-xs text-gray-400">Только латиница, цифры, дефис и подчёркивание — используется как идентификатор.</p>
|
||||
<div v-if="form.errors.code" class="mt-2 text-sm text-red-600">{{ form.errors.code }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="category" class="block font-medium text-sm text-gray-700">Категория</label>
|
||||
<select
|
||||
id="category"
|
||||
v-model="form.category"
|
||||
required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<option value="">— выбрать —</option>
|
||||
<option value="sensor">Датчик</option>
|
||||
<option value="actuator">Исполнитель</option>
|
||||
</select>
|
||||
<div v-if="form.errors.category" class="mt-2 text-sm text-red-600">{{ form.errors.category }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="capability" class="block font-medium text-sm text-gray-700">Возможности</label>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Для датчика — типы показаний (например, temperature). Для исполнителя — команды
|
||||
(например, turn_on, turn_off, set_level).
|
||||
</p>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<input
|
||||
id="capability"
|
||||
v-model="capabilityInput"
|
||||
type="text"
|
||||
placeholder="turn_on"
|
||||
class="block w-full font-mono border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
@keydown.enter.prevent="addCapability"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 inline-flex items-center px-4 py-2 bg-white border border-gray-300 rounded-md font-semibold text-xs text-gray-700 uppercase tracking-widest hover:bg-gray-50"
|
||||
@click="addCapability"
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="form.capabilities.length" class="mt-3 flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="(capability, index) in form.capabilities"
|
||||
:key="capability"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600"
|
||||
>
|
||||
{{ capability }}
|
||||
<button type="button" class="text-gray-400 hover:text-red-600" @click="removeCapability(index)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="form.errors.capabilities" class="mt-2 text-sm text-red-600">{{ form.errors.capabilities }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<a :href="routes.deviceTypes.index" class="text-sm text-gray-600 hover:underline">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import DeviceTypeIcon from '@/Components/DeviceTypeIcon.vue';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
defineProps({
|
||||
deviceTypes: { type: Array, required: true },
|
||||
});
|
||||
|
||||
const isOwner = computed(() => usePage().props.auth.user?.role === 'owner');
|
||||
|
||||
const categoryLabel = { sensor: 'Датчик', actuator: 'Исполнитель' };
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<Link :href="routes.devices.index" class="text-sm text-gray-500 hover:text-gray-700">← Устройства</Link>
|
||||
<h2 class="mt-1 font-semibold text-xl text-gray-800 leading-tight">Типы устройств</h2>
|
||||
</div>
|
||||
<Link
|
||||
v-if="isOwner"
|
||||
:href="routes.deviceTypes.create"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700"
|
||||
>
|
||||
Добавить тип
|
||||
</Link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div v-if="deviceTypes.length === 0" class="bg-white rounded-xl border border-dashed border-gray-300 p-10 text-center text-sm text-gray-500">
|
||||
Типов устройств пока нет.
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="type in deviceTypes"
|
||||
:key="type.id"
|
||||
class="flex flex-col gap-4 rounded-xl border border-gray-200 bg-white p-5"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<DeviceTypeIcon :category="type.category" />
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">{{ type.code }}</div>
|
||||
<div class="text-xs text-gray-500">{{ categoryLabel[type.category] ?? type.category }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="capability in type.capabilities"
|
||||
:key="capability"
|
||||
class="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600"
|
||||
>
|
||||
{{ capability }}
|
||||
</span>
|
||||
<span v-if="type.capabilities.length === 0" class="text-xs text-gray-400">Без возможностей</span>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-100 pt-3 text-xs text-gray-400">
|
||||
{{ type.devices_count }} {{ type.devices_count === 1 ? 'устройство' : 'устройств' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -23,13 +23,16 @@ function destroy(device) {
|
||||
<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 class="flex items-center gap-3">
|
||||
<Link :href="routes.deviceTypes.index" class="text-sm text-gray-500 hover:text-gray-700">Типы устройств</Link>
|
||||
<Link
|
||||
v-if="isOwner"
|
||||
:href="routes.devices.create"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700"
|
||||
>
|
||||
Добавить устройство
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ export const routes = {
|
||||
turnOff: (id) => `/devices/${id}/turn-off`,
|
||||
setLevel: (id) => `/devices/${id}/set-level`,
|
||||
},
|
||||
deviceTypes: {
|
||||
index: '/device-types',
|
||||
create: '/device-types/create',
|
||||
store: '/device-types',
|
||||
},
|
||||
automationRules: {
|
||||
index: '/automation-rules',
|
||||
create: '/automation-rules/create',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Http\Controllers\AutomationRuleController;
|
||||
use App\Http\Controllers\DeviceController;
|
||||
use App\Http\Controllers\DeviceTypeController;
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use App\Http\Controllers\ZoneController;
|
||||
use App\Models\AutomationRule;
|
||||
@@ -32,6 +33,7 @@ Route::get('/dashboard', function (DeviceShadow $shadow) {
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::resource('zones', ZoneController::class)->except('show');
|
||||
Route::resource('devices', DeviceController::class);
|
||||
Route::resource('device-types', DeviceTypeController::class)->only(['index', 'create', 'store']);
|
||||
Route::resource('automation-rules', AutomationRuleController::class)
|
||||
->except('show')
|
||||
->parameters(['automation-rules' => 'automation_rule']);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Models\DeviceType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DeviceTypeControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_owner_can_create_device_type(): void
|
||||
{
|
||||
$owner = User::factory()->create(['role' => UserRole::Owner]);
|
||||
|
||||
$response = $this->actingAs($owner)->post(route('device-types.store'), [
|
||||
'code' => 'smart_socket',
|
||||
'category' => 'actuator',
|
||||
'capabilities' => ['turn_on', 'turn_off'],
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('device-types.index'));
|
||||
$this->assertDatabaseHas('device_types', [
|
||||
'code' => 'smart_socket',
|
||||
'category' => 'actuator',
|
||||
]);
|
||||
$this->assertSame(
|
||||
['turn_on', 'turn_off'],
|
||||
DeviceType::where('code', 'smart_socket')->first()->capabilities,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_viewer_cannot_create_device_type(): void
|
||||
{
|
||||
$viewer = User::factory()->create(['role' => UserRole::Viewer]);
|
||||
|
||||
$response = $this->actingAs($viewer)->post(route('device-types.store'), [
|
||||
'code' => 'smart_socket',
|
||||
'category' => 'actuator',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseMissing('device_types', ['code' => 'smart_socket']);
|
||||
}
|
||||
|
||||
public function test_code_must_be_unique(): void
|
||||
{
|
||||
$owner = User::factory()->create(['role' => UserRole::Owner]);
|
||||
DeviceType::create(['code' => 'fan', 'category' => 'actuator', 'capabilities' => []]);
|
||||
|
||||
$response = $this->actingAs($owner)->post(route('device-types.store'), [
|
||||
'code' => 'fan',
|
||||
'category' => 'actuator',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('code');
|
||||
}
|
||||
|
||||
public function test_viewer_can_view_device_types_index(): void
|
||||
{
|
||||
$viewer = User::factory()->create(['role' => UserRole::Viewer]);
|
||||
DeviceType::create(['code' => 'fan', 'category' => 'actuator', 'capabilities' => ['turn_on']]);
|
||||
|
||||
$response = $this->actingAs($viewer)->get(route('device-types.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('DeviceTypes/Index')
|
||||
->where('deviceTypes.0.code', 'fan')
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user