Explorar o código

Users and roles

Alexander Musikhin hai 11 meses
pai
achega
03bcf129b1
Modificáronse 39 ficheiros con 816 adicións e 48 borrados
  1. 37 0
      app/Helpers/dateHelper.php
  2. 32 0
      app/Helpers/roles.php
  3. 18 0
      app/Helpers/taskStatuses.php
  4. 81 0
      app/Http/Controllers/UserController.php
  5. 25 0
      app/Http/Middleware/EnsureUserHasRole.php
  6. 25 0
      app/Http/Requests/DeleteUser.php
  7. 43 0
      app/Http/Requests/StoreUser.php
  8. 19 0
      app/Models/Role.php
  9. 0 15
      app/Models/Roles.php
  10. 27 0
      app/Providers/HelperServiceProvider.php
  11. 4 1
      bootstrap/app.php
  12. 1 0
      bootstrap/providers.php
  13. 2 2
      database/seeders/DatabaseSeeder.php
  14. 5 0
      resources/sass/app.scss
  15. 0 2
      resources/views/auth/login.blade.php
  16. 0 2
      resources/views/auth/passwords/confirm.blade.php
  17. 0 2
      resources/views/auth/passwords/email.blade.php
  18. 0 2
      resources/views/auth/passwords/reset.blade.php
  19. 0 2
      resources/views/auth/register.blade.php
  20. 0 2
      resources/views/auth/verify.blade.php
  21. 12 14
      resources/views/home.blade.php
  22. 5 3
      resources/views/layouts/app.blade.php
  23. 5 0
      resources/views/layouts/menu.blade.php
  24. 20 0
      resources/views/partials/avatars.blade.php
  25. 15 0
      resources/views/partials/checkbox.blade.php
  26. 54 0
      resources/views/partials/color.blade.php
  27. 17 0
      resources/views/partials/file.blade.php
  28. 33 0
      resources/views/partials/input.blade.php
  29. 46 0
      resources/views/partials/projects.blade.php
  30. 13 0
      resources/views/partials/select.blade.php
  31. 17 0
      resources/views/partials/submit.blade.php
  32. 84 0
      resources/views/partials/tasks.blade.php
  33. 15 0
      resources/views/partials/textarea.blade.php
  34. 9 0
      resources/views/partials/user.blade.php
  35. 31 0
      resources/views/users/edit.blade.php
  36. 43 0
      resources/views/users/index.blade.php
  37. 64 0
      resources/views/users/profile.blade.php
  38. 13 0
      routes/web.php
  39. 1 1
      todo.md

+ 37 - 0
app/Helpers/dateHelper.php

@@ -0,0 +1,37 @@
+<?php
+use Illuminate\Support\Carbon;
+
+if(!function_exists('humanDate')) {
+    function humanDate($date, $withTime = false): string
+    {
+        if(!$date) return '-';
+
+        $today = date('Y-m-d');
+        $tomorrow = date('Y-m-d', strtotime('tomorrow'));
+
+        $ret = '';
+
+        switch ($date) {
+            case $today:
+                $ret .= 'Сегодня';
+                break;
+            case $tomorrow:
+                $ret .= 'Завтра';
+                break;
+            default:
+                $ret .= Carbon::parse($date)->isoFormat('D MMMM');
+        }
+
+        $year = date('Y', strtotime($date));
+        if(date('Y') != $year){
+            $ret .= ' ' . $year;
+        }
+
+        if($withTime) {
+            $ret .= date(' H:i', strtotime($date));
+        }
+
+        return  $ret;
+    }
+}
+

+ 32 - 0
app/Helpers/roles.php

@@ -0,0 +1,32 @@
+<?php
+
+use App\Models\Role;
+
+if(!function_exists('getRoles')){
+    function getRoles($key = null): array|string
+    {
+        $roles = Role::NAMES;
+        if($key && isset($roles[$key])){
+            return $roles[$key];
+        } else {
+            return $roles;
+        }
+    }
+}
+
+if(!function_exists('hasRole')){
+    function hasRole($roles, $user = null) : bool
+    {
+        if(!$user) $user = auth()->user();
+
+        $roles = explode(',', $roles);
+        return (in_array($user->role, $roles));
+    }
+}
+
+if(!function_exists('roleName')) {
+    function roleName($role): string
+    {
+        return Role::NAMES[$role];
+    }
+}

+ 18 - 0
app/Helpers/taskStatuses.php

@@ -0,0 +1,18 @@
+<?php
+if(!function_exists('getStatuses')){
+    function getStatuses($key = null): array|string
+    {
+        $statuses = [
+            'work'      => 'В работе',
+            'check'     => 'На проверке',
+            'done'      => 'Завершена',
+            'cancel'    => 'Отменена',
+        ];
+
+        if($key && isset($statuses[$key])){
+            return $statuses[$key];
+        } else {
+            return $statuses;
+        }
+    }
+}

+ 81 - 0
app/Http/Controllers/UserController.php

@@ -0,0 +1,81 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\Requests\DeleteUser;
+use App\Http\Requests\StoreUser;
+use App\Models\User;
+use Illuminate\Support\Facades\Hash;
+
+class UserController extends Controller
+{
+    protected array $data = [
+        'active' => 'users',
+        'title'  => 'Пользователи',
+    ];
+
+
+    /**
+     * Display a listing of the resource.
+     */
+    public function index()
+    {
+        $this->data['users'] = User::query()->get();
+        return view('users.index', $this->data);
+    }
+
+    /**
+     * Show the form for creating a new resource.
+     */
+    public function create()
+    {
+        $this->data['user'] = null;
+        return view('users.edit', $this->data);
+    }
+
+    /**
+     * Store a newly or update existing created resource in storage.
+     */
+    public function store(StoreUser $request)
+    {
+        $validated = $request->validated();
+
+        if(!empty($validated['password'])) {
+            $validated['password'] = Hash::make($validated['password']);
+        } else {
+            unset($validated['password']);
+        }
+
+        if(isset($validated['id'])) {
+            User::query()
+                ->where('id', $validated['id'])
+                ->update($validated);
+        } else {
+            User::query()->create($validated);
+        }
+        return redirect()->route('user.index')->with(['success' => 'Пользователь ' . $validated['name'] . ' сохранён!']);
+    }
+
+    /**
+     * Display the specified resource.
+     */
+    public function show(User $user)
+    {
+        $this->data['user'] = $user;
+        return view('users.edit', $this->data);
+    }
+
+
+    /**
+     * Remove the specified resource from storage.
+     */
+    public function destroy(User $user, DeleteUser $request)
+    {
+        if($user == $request->user()) {
+            return redirect()->route('user.index')->with(['danger' => 'Нельзя удалить самого себя!']);
+        } else {
+            $user->delete();
+            return redirect()->route('user.index')->with(['success' => 'Пользователь ' . $user->name . ' удалён!']);
+        }
+    }
+}

+ 25 - 0
app/Http/Middleware/EnsureUserHasRole.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Http\Middleware;
+
+use Closure;
+use Illuminate\Foundation\Support\Providers\RouteServiceProvider;
+use Illuminate\Http\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+class EnsureUserHasRole
+{
+    /**
+     * Handle an incoming request.
+     *
+     * @param  \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response)  $next
+     */
+    public function handle(Request $request, Closure $next, ... $roles): Response
+    {
+        if(in_array($request->user()->role, $roles)) {
+            return $next($request);
+        }
+
+        return redirect('/home');
+    }
+}

+ 25 - 0
app/Http/Requests/DeleteUser.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class DeleteUser extends FormRequest
+{
+    public function prepareForValidation(): void
+    {
+        $this->merge(['id' => $this->route('user')->id]);
+    }
+
+    public function rules(): array
+    {
+        return [
+            'id' => 'required|exists:users',
+        ];
+    }
+
+    public function authorize(): bool
+    {
+        return hasRole('admin');
+    }
+}

+ 43 - 0
app/Http/Requests/StoreUser.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Http\Requests;
+
+use App\Models\Role;
+use Illuminate\Foundation\Http\FormRequest;
+
+class StoreUser extends FormRequest
+{
+    /**
+     * Determine if the user is authorized to make this request.
+     */
+    public function authorize(): bool
+    {
+        return hasRole('admin');
+    }
+
+    /**
+     * Get the validation rules that apply to the request.
+     *
+     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
+     */
+    public function rules(): array
+    {
+        return [
+            'id'        => 'nullable|exists:users',
+            'email'     => 'required_if:id,null|email|unique:users,email,'.$this->id,
+            'name'      => 'required|string|min:2',
+            'password'  => 'required_without:id|nullable|string|min:4',
+            'role'      => 'nullable|string|in:' . implode(',', Role::VALID_ROLES),
+        ];
+    }
+
+    public function messages()
+    {
+        return [
+            'name'      => 'Поле обязательно для заполнения и должно быть не менее 2 символов!',
+            'password'  => 'Пароль должен быть не менее 8 символов!',
+            'role'      => 'Неправильная роль!',
+            'email'     => 'Нужно указать правильный адрес почты, который не зарегистрирован в системе!',
+        ];
+    }
+}

+ 19 - 0
app/Models/Role.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Models;
+
+class Role
+{
+    const ADMIN     = 'admin';
+    const MANAGER   = 'manager';
+
+    const VALID_ROLES = [
+        self::ADMIN,
+        self::MANAGER,
+    ];
+
+    const NAMES = [
+        self::ADMIN     => 'Админ',
+        self::MANAGER   => 'Менеджер'
+    ];
+}

+ 0 - 15
app/Models/Roles.php

@@ -1,15 +0,0 @@
-<?php
-
-namespace App\Models;
-
-class Roles
-{
-    const ADMIN = 'admin';
-    const MANAGER = 'manager';
-
-    const VALID_ROLES = [
-        self::ADMIN,
-        self::MANAGER,
-    ];
-
-}

+ 27 - 0
app/Providers/HelperServiceProvider.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace App\Providers;
+
+use Illuminate\Support\ServiceProvider;
+
+class HelperServiceProvider extends ServiceProvider
+{
+    /**
+     * Register services.
+     */
+    public function register(): void
+    {
+        $files = glob(app_path('Helpers') . "/*.php");
+        foreach ($files as $key => $file) {
+            require_once $file;
+        }
+    }
+
+    /**
+     * Bootstrap services.
+     */
+    public function boot(): void
+    {
+        //
+    }
+}

+ 4 - 1
bootstrap/app.php

@@ -1,5 +1,6 @@
 <?php
 
+use App\Http\Middleware\EnsureUserHasRole;
 use Illuminate\Foundation\Application;
 use Illuminate\Foundation\Configuration\Exceptions;
 use Illuminate\Foundation\Configuration\Middleware;
@@ -11,7 +12,9 @@ return Application::configure(basePath: dirname(__DIR__))
         health: '/up',
     )
     ->withMiddleware(function (Middleware $middleware) {
-        //
+        $middleware->alias([
+            'role' => EnsureUserHasRole::class,
+        ]);
     })
     ->withExceptions(function (Exceptions $exceptions) {
         //

+ 1 - 0
bootstrap/providers.php

@@ -2,4 +2,5 @@
 
 return [
     App\Providers\AppServiceProvider::class,
+    App\Providers\HelperServiceProvider::class,
 ];

+ 2 - 2
database/seeders/DatabaseSeeder.php

@@ -2,7 +2,7 @@
 
 namespace Database\Seeders;
 
-use App\Models\Roles;
+use App\Models\Role;
 use App\Models\User;
 // use Illuminate\Database\Console\Seeds\WithoutModelEvents;
 use Illuminate\Database\Seeder;
@@ -20,7 +20,7 @@ class DatabaseSeeder extends Seeder
         User::factory()->create([
             'name' => 'Alexander M',
             'email' => 'dth.pto@gmail.com',
-            'role' => Roles::ADMIN,
+            'role' => Role::ADMIN,
             'password' => Hash::make('1323'),
         ]);
     }

+ 5 - 0
resources/sass/app.scss

@@ -7,3 +7,8 @@
 // Bootstrap
 @import 'bootstrap/scss/bootstrap';
 
+@import "bootstrap-icons/font/bootstrap-icons.css";
+
+.invalid-feedback {
+  display: block !important;
+}

+ 0 - 2
resources/views/auth/login.blade.php

@@ -1,7 +1,6 @@
 @extends('layouts.app')
 
 @section('content')
-<div class="container">
     <div class="row justify-content-center">
         <div class="col-md-8">
             <div class="card">
@@ -69,5 +68,4 @@
             </div>
         </div>
     </div>
-</div>
 @endsection

+ 0 - 2
resources/views/auth/passwords/confirm.blade.php

@@ -1,7 +1,6 @@
 @extends('layouts.app')
 
 @section('content')
-<div class="container">
     <div class="row justify-content-center">
         <div class="col-md-8">
             <div class="card">
@@ -45,5 +44,4 @@
             </div>
         </div>
     </div>
-</div>
 @endsection

+ 0 - 2
resources/views/auth/passwords/email.blade.php

@@ -1,7 +1,6 @@
 @extends('layouts.app')
 
 @section('content')
-<div class="container">
     <div class="row justify-content-center">
         <div class="col-md-8">
             <div class="card">
@@ -43,5 +42,4 @@
             </div>
         </div>
     </div>
-</div>
 @endsection

+ 0 - 2
resources/views/auth/passwords/reset.blade.php

@@ -1,7 +1,6 @@
 @extends('layouts.app')
 
 @section('content')
-<div class="container">
     <div class="row justify-content-center">
         <div class="col-md-8">
             <div class="card">
@@ -61,5 +60,4 @@
             </div>
         </div>
     </div>
-</div>
 @endsection

+ 0 - 2
resources/views/auth/register.blade.php

@@ -1,7 +1,6 @@
 @extends('layouts.app')
 
 @section('content')
-<div class="container">
     <div class="row justify-content-center">
         <div class="col-md-8">
             <div class="card">
@@ -73,5 +72,4 @@
             </div>
         </div>
     </div>
-</div>
 @endsection

+ 0 - 2
resources/views/auth/verify.blade.php

@@ -1,7 +1,6 @@
 @extends('layouts.app')
 
 @section('content')
-<div class="container">
     <div class="row justify-content-center">
         <div class="col-md-8">
             <div class="card">
@@ -24,5 +23,4 @@
             </div>
         </div>
     </div>
-</div>
 @endsection

+ 12 - 14
resources/views/home.blade.php

@@ -1,21 +1,19 @@
 @extends('layouts.app')
 
 @section('content')
-    <div class="container">
-        <div class="row justify-content-center">
-            <div class="col-md-8">
-                <div class="card">
-                    <div class="card-header">{{ __('Dashboard') }}</div>
+    <div class="row justify-content-center">
+        <div class="col-md-8">
+            <div class="card">
+                <div class="card-header">{{ __('Dashboard') }}</div>
 
-                    <div class="card-body">
-                        @if (session('status'))
-                            <div class="alert alert-success" role="alert">
-                                {{ session('status') }}
-                            </div>
-                        @endif
-                        Это победа? вася
-                        {{ __('You are logged in!') }}
-                    </div>
+                <div class="card-body">
+                    @if (session('status'))
+                        <div class="alert alert-success" role="alert">
+                            {{ session('status') }}
+                        </div>
+                    @endif
+                    Это победа? 123
+                    {{ __('You are logged in!') }}
                 </div>
             </div>
         </div>

+ 5 - 3
resources/views/layouts/app.blade.php

@@ -30,7 +30,7 @@
                 <div class="collapse navbar-collapse" id="navbarSupportedContent">
                     <!-- Left Side Of Navbar -->
                     <ul class="navbar-nav me-auto">
-
+                        @include('layouts.menu')
                     </ul>
 
                     <!-- Right Side Of Navbar -->
@@ -50,7 +50,7 @@
                             @endif
                         @else
                             <li class="nav-item dropdown">
-                                <a id="navbarDropdown" class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" v-pre>
+                                <a id="navbarDropdown" class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                                     {{ Auth::user()->name }}
                                 </a>
 
@@ -73,7 +73,9 @@
         </nav>
 
         <main class="py-4">
-            @yield('content')
+            <div class="container">
+                @yield('content')
+            </div>
         </main>
     </div>
 </body>

+ 5 - 0
resources/views/layouts/menu.blade.php

@@ -0,0 +1,5 @@
+
+@if(hasrole('admin'))
+    <li class="nav-item"><a class="nav-link" href="{{ route('user.index') }}">Пользователи</a></li>
+@endif
+<li class="nav-item"><a class="nav-link" href="">меннн</a></li>

+ 20 - 0
resources/views/partials/avatars.blade.php

@@ -0,0 +1,20 @@
+<div class="row mb-3 align-items-center">
+    <label class="col-form-label col-md-4 text-md-end">Аватар</label>
+    <div class="col-md-8 avatars-select mb-3 d-flex justify-content-start flex-wrap">
+        @foreach($avatars as $avatar)
+            @php($avatar->path = basename($avatar->path))
+            @php($disabled = ($avatar->experience > $user?->experience ))
+            <div class="text-center p-1">
+
+                <input  type="radio" @disabled($disabled)
+                        class="form-check-input mt-2 visually-hidden" name="avatar"
+                        id="avatar-{{ $avatar->id }}" value="{{ $avatar->id }}" @checked($avatar->path == ($user?->avatar ?? 'default.png'))>
+                <label for="avatar-{{ $avatar->id }}"
+                       class="d-block @if($disabled) disabled @endif {{ ($avatar->path == $user?->avatar) ? 'current' : '' }}"
+                       title="Необходимый опыт: {{ $avatar->experience }}">
+                    <img class="img-fluid rounded-circle" style="background-color: {{ $user->bg_color ?? '#55def7' }}" src="{{ asset('images/avatars/' . $avatar->path) }}" alt="{{ $avatar->id }}">
+                </label>
+            </div>
+        @endforeach
+    </div>
+</div>

+ 15 - 0
resources/views/partials/checkbox.blade.php

@@ -0,0 +1,15 @@
+<div class="row align-items-center">
+    <label for="{{ $name }}-{{ $value }}" class="col-form-label @if(!($right ?? null)) col-8 col-md-4 text-md-end @endif">
+        {{ $title ?? '' }}
+        @isset($required) <sup>*</sup> @endisset
+    </label>
+    <div class="@if(!($right ?? null)) col-4 col-md-8 pt-0 @endif form-check form-switch d-flex justify-content-end justify-content-md-start">
+        <input type="{{ $type ?? 'checkbox' }}" name="{{ $name }}" id="{{ $name }}-{{ $value }}"
+               @checked($checked)
+               class="form-check-input @error($name) is-invalid @enderror" @disabled($disabled ?? null)
+               value="{{ old($name, $value ?? '') }}">
+        @error($name)
+        <span class="invalid-feedback" role="alert"><strong>{{ $message }}</strong></span>
+        @enderror
+    </div>
+</div>

+ 54 - 0
resources/views/partials/color.blade.php

@@ -0,0 +1,54 @@
+<div class="row mb-3 align-items-center">
+    <label for="{{ $name }}" class="col-form-label @if(!($right ?? null)) col-md-4 text-md-end @endif">
+        {{ $title ?? '' }}
+        @isset($required) <sup>*</sup> @endisset
+    </label>
+    <div class="@if(!($right ?? null)) col-md-8 @endif ">
+        <div class="input-group">
+            <input type="color" name="{{ $name }}" id="{{ $name }}" style="height: 32px"
+                   class="form-control @error($name) is-invalid @enderror" @disabled($disabled ?? null) @required($required ?? null)
+                   @isset($min) min="{{ $min }}" @endisset
+                   @isset($max) max="{{ $max }}" @endisset
+                   @isset($pattern) pattern="{{ $pattern }}" @endisset
+                   placeholder="{{ $placeholder ?? ''}}"
+                   @isset($multiple) multiple="multiple" @endisset
+                   value="{{ old($name, $value ?? '') }}" autocomplete="{{ $name }}">
+            <button class="btn btn-sm btn-outline-info" type="button" id="change-color">Рандомный цвет</button>
+        </div>
+
+
+
+        @error($name)
+        <span class="invalid-feedback" role="alert"><strong>{{ $message }}</strong></span>
+        @enderror
+    </div>
+</div>
+
+@push('scripts')
+    <script type="module">
+        $('#bg_color').on('change input', function (){
+            $('.avatars-select label:not(.disabled) img').css('background-color', $(this).val());
+        });
+
+        $('#change-color').on('click', function (){
+            let h = Math.random() * 360;
+            let s = (Math.random() * 20) + 70;
+            let l = (Math.random() * 20) + 70;
+            let color = hslToHex(h, s, l);
+            $('#bg_color').val(color).trigger('change');
+        });
+
+
+        function hslToHex(h, s, l) {
+            l /= 100;
+            const a = s * Math.min(l, 1 - l) / 100;
+            const f = n => {
+                const k = (n + h / 30) % 12;
+                const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
+                return Math.round(255 * color).toString(16).padStart(2, '0');   // convert to Hex and prefix "0" if needed
+            };
+            return `#${f(0)}${f(8)}${f(4)}`;
+        }
+
+    </script>
+@endpush

+ 17 - 0
resources/views/partials/file.blade.php

@@ -0,0 +1,17 @@
+<div class="row mb-3">
+    <label for="{{ $name }}" class="col-form-label @if(!($right ?? null)) col-md-4 text-md-end @endif mt-1">
+        {{ $title ?? '' }}
+        @isset($required) <sup>*</sup> @endisset
+    </label>
+    <div class="@if(!($right ?? null)) col-md-8 @endif">
+        <input type="{{ $type ?? 'text' }}" name="{{ $name }}" id="{{ $name }}"
+               class="form-control @error($name) is-invalid @enderror" @disabled($disabled ?? null) @required($required ?? null)
+               @isset($min) min="{{ $min }}" @endisset
+               @isset($max) max="{{ $max }}" @endisset
+               value="{{ old($name, $value ?? '') }}" autocomplete="{{ $name }}">
+        @error($name)
+        <span class="invalid-feedback" role="alert"><strong>{{ $message }}</strong></span>
+        @enderror
+    </div>
+</div>
+

+ 33 - 0
resources/views/partials/input.blade.php

@@ -0,0 +1,33 @@
+<div class="row mb-3">
+    <label for="{{ $name }}" class="col-form-label @if(!($right ?? null)) col-md-4 text-md-end @endif">
+        {{ $title ?? '' }}
+        @isset($required) <sup>*</sup> @endisset
+    </label>
+    <div class="@if(!($right ?? null)) col-md-8 @endif">
+        <div class="input-group">
+        <input type="{{ $type ?? 'text' }}" name="{{ $name }}" id="{{ $name }}"
+               class="form-control @error($name) is-invalid @enderror" @disabled($disabled ?? null) @required($required ?? null)
+                @isset($min) min="{{ $min }}" @endisset
+                @isset($max) max="{{ $max }}" @endisset
+                @isset($pattern) pattern="{{ $pattern }}" @endisset
+                placeholder="{{ $placeholder ?? ''}}"
+                @isset($multiple) multiple="multiple" @endisset
+                @isset($datalist) list="dl-{{ $name }}" @endisset
+               value="{{ old($name, $value ?? '') }}" autocomplete="off">
+            @isset($datalist)
+                <datalist id="dl-{{ $name }}">
+                    @foreach($datalist as $s)
+                        <option value="{{ $s }}"></option>
+                    @endforeach
+                </datalist>
+            @endisset
+
+            @isset($button)
+                <button class="btn btn-outline-info" type="button" id="{{ $button }}">{{ $buttonText ?? 'Имя' }}</button>
+            @endisset
+        </div>
+        @error($name)
+        <div class="invalid-feedback" role="alert"><strong>{{ $message }}</strong></div>
+        @enderror
+    </div>
+</div>

+ 46 - 0
resources/views/partials/projects.blade.php

@@ -0,0 +1,46 @@
+<div class="d-md-flex d-none row header px-5 py-4 fw-bold">
+    <div class="col-xl-4">Проект</div>
+    <div class="col-xl-2">Менеджер</div>
+    <div class="col-xl-2">Заказчик</div>
+    <div class="col-xl-2">Дата создания</div>
+    <div class="col-xl-2 text-end">Действия</div>
+</div>
+
+@foreach($projects as $project)
+    <div class="row px-md-5 px-3 py-3 align-items-center">
+        <div class="col-xl-4">
+            <a class="m-0 p-0 d-block" href="{{ route('projects.view', $project->id) }}">
+                <span class="d-md-none fw-bold">Проект: </span>{{ $project->name }}</a>
+            <a class="m-0 p-0 d-block text-secondary small" target="_blank" href="http://{{ ($project->domains->first()) ? $project->domains->first()->name : '' }}">{{ ($project->domains->first()) ? $project->domains->first()->name : '' }}</a>
+        </div>
+        <div class="col-xl-2">
+            <div class="m-0 p-0">
+                @include('partials.user', ['user' => $project->manager, 'role' => 'Менеджер'])
+            </div>
+        </div>
+        <div class="col-xl-2">
+            <div class="m-0 p-0">
+                @include('partials.user', ['user' => $project->customer, 'role' => 'Заказчик'])
+            </div>
+        </div>
+        <div class="col-xl-2">
+            <div class="m-0 p-0">
+                <span class="d-md-none fw-bold">Создан: </span>{{ humandate($project->created_at) }}
+            </div>
+        </div>
+        <div class="col-xl-2 text-end">
+            @if(hasrole('admin') || (hasrole('manager') && ($project->manager_id == auth()->user()->id)))
+                <a href="{{ route('projects.edit', $project->id) }}">
+                    <span class="edit"></span>
+                </a>
+                <a href="#" onclick="if(confirm('Удалить проект {{ $project->name }}?')) { document.getElementById('delete-project-{{ $project->id }}').submit(); } ">
+                    <span class="trash"></span>
+                </a>
+                <form action="{{ route('projects.delete', $project->id) }}" method="post" class="visually-hidden d-none" id="delete-project-{{ $project->id }}">
+                    @csrf
+                    @method('DELETE')
+                </form>
+            @endif
+        </div>
+    </div>
+@endforeach

+ 13 - 0
resources/views/partials/select.blade.php

@@ -0,0 +1,13 @@
+<div class="row mb-{{ ($mb ?? 3) }}">
+    <label for="{{ $name }}" class="col-form-label @if(!($right ?? null)) col-md-4 text-md-end @endif mt-1">{{ $title }}</label>
+    <div class="@if(!($right ?? null)) col-md-8 @endif">
+        <select name="{{ $name }}" id="{{ $name }}" class="form-select @error($name) is-invalid @enderror" >
+            @foreach($options as $k => $v)
+                <option @selected($k == ($value ?? null)) value="{{ $k }}">{{ $v }}</option>
+            @endforeach
+        </select>
+        @error($name)
+        <span class="invalid-feedback" role="alert"><strong>{{ $message }}</strong></span>
+        @enderror
+    </div>
+</div>

+ 17 - 0
resources/views/partials/submit.blade.php

@@ -0,0 +1,17 @@
+<div class="row">
+    <div class="@if(!($right ?? null)) offset-md-{{ $offset ?? 4 }} col-md-8 @endif">
+        <button type="submit" class="btn btn-info sbmt text-white">{{ $name ?? 'Сохранить' }}</button>
+    </div>
+</div>
+
+@push('scripts')
+    <script type="module">
+        $('.sbmt').on('click', function (){
+            $(this).addClass('d-none');
+            setTimeout(function () {
+                $('.sbmt').removeClass('d-none');
+                },
+                2000);
+        });
+    </script>
+@endpush

+ 84 - 0
resources/views/partials/tasks.blade.php

@@ -0,0 +1,84 @@
+
+    <div class="row header px-5 py-4 fw-bold d-none d-md-flex">
+        <div class="col-xl-4">Задача</div>
+        <div class="col-xl-2">Проект</div>
+        <div class="col-xl-2">Дедлайн</div>
+        <div class="col-xl-2">Исполнитель</div>
+        <div class="col-xl-2 text-end">Действия</div>
+    </div>
+
+
+    @foreach($tasks as $task)
+    <div class="row px-2 px-md-5 py-4 align-items-center task-{{ $task->status }}">
+        <div class="col-xl-4">
+            <a class="p-0 m-0" title="Развернуть описание" data-bs-toggle="collapse" href="#description-{{ $task->id }}" role="button" aria-expanded="false" aria-controls="description-{{ $task->id }}">
+                <span class="d-md-none fw-bold">Задача: </span>{{ $task->name }}
+            </a>
+            <div class="p-0 m-0 text-secondary small">Статус: {{ getStatuses($task->status) }}</div>
+        </div>
+        <div class="col-xl-2">
+            <a class="m-0 p-0 d-md-block" href="{{ route('projects.view', $task->project->id) }}">
+                <span class="d-md-none fw-bold">Проект: </span>{{ $task->project->name }}
+            </a>
+            <a class="m-0 p-0 d-block text-secondary small" target="_blank" href="{{ ($task->project->domains->first()) ? $task->project->domains->first()->name : '' }}">{{ ($task->project->domains->first()) ? $task->project->domains->first()->name : '' }}</a>
+        </div>
+        <div class="col-xl-2 @if(strtotime($task->deadline) < strtotime('now +2 day')) text-danger @endif">
+            <div class="m-0 p-0">
+                <span class="d-md-none fw-bold">Дедлайн: </span>{{ humanDate($task->deadline) }}
+                @if(strtotime($task->deadline) < strtotime('now +2 day'))
+                <img src="{{ asset('ico/flash.svg') }}" alt="ALERT" class="ms-1">
+                @endif
+            </div>
+        </div>
+        <div class="col-xl-2">
+            <div class="m-0 p-0">
+                @include('partials.user', ['user' => $task->executor, 'role' => 'Исполнитель'])
+            </div>
+        </div>
+        <div class="col-xl-2 justify-content-end mt-3 mt-md-1 d-flex">
+            @hasrole('manager')
+                @if(auth()->user()->id == $task->project->manager_id)
+                    @if(in_array($task->status, ['check', 'work']))
+                        <a href="{{ route('tasks.status-change', 'done/' . $task->id) }}" class="small" title="Завершить">
+                            <div class="check"></div>
+                        </a>
+                        @if($task->status != 'work')
+                            <a href="{{ route('tasks.status-change', 'work/' . $task->id) }}" class="small" title="В работу">
+                                <span class="refresh"></span>
+                            </a>
+                        @endif
+                    @elseif(!in_array($task->status, ['done', 'cancel']))
+                        <a href="{{ route('tasks.status-change', 'cancel/' . $task->id) }}" class="small" title="Отменить">
+                            <span class="cancel"></span>
+                        </a>
+                   @endif
+                @endif
+            @endhasrole
+
+            @hasrole('executor')
+                @if(in_array($task->status, ['work']) && ($task->executor_id == auth()->user()->id) && ($task->project->manager_id != $task->executor_id))
+                    <a href="{{ route('tasks.status-change', 'check/' . $task->id) }}" class="small" title="На проверку">
+                         <div class="check"></div>
+                    </a>
+                @endif
+            @endhasrole
+
+            @hasrole('admin,manager')
+                <a href="{{ route('tasks.edit', $task->id) }}">
+                    <div class="edit"></div>
+                </a>
+                <a href="#" onclick="if(confirm('Удалить задачу {{ $task->name }}?')) { document.getElementById('delete-task-{{ $task->id }}').submit(); } ">
+                    <div class="trash"></div></a>
+                <form action="{{ route('tasks.delete', $task->id) }}" method="post" class="visually-hidden d-none" id="delete-task-{{ $task->id }}">
+                    @csrf
+                    @method('DELETE')
+                </form>
+            @endhasrole
+
+        </div>
+        <div class="col-12 collapse mt-4" id="description-{{ $task->id }}">
+            <pre class="p-3 rounded">{{ $task->description }}</pre>
+        </div>
+    </div>
+
+    @endforeach

+ 15 - 0
resources/views/partials/textarea.blade.php

@@ -0,0 +1,15 @@
+<div class="row mb-3">
+    <label for="{{ $name }}" class="col-form-label col-md-4 text-md-end mt-1">
+        {{ $title ?? '' }}
+        @isset($required) <sup>*</sup> @endisset
+    </label>
+    <div class="col-md-8">
+        <textarea type="{{ $type ?? 'text' }}" name="{{ $name }}" id="{{ $name }}"
+               class="form-control @error($name) is-invalid @enderror" @disabled($disabled ?? null) @required($required ?? null)
+        >{{ old($name, $value ?? '') }}</textarea>
+        @error($name)
+        <span class="invalid-feedback" role="alert"><strong>{{ $message }}</strong></span>
+        @enderror
+    </div>
+</div>
+

+ 9 - 0
resources/views/partials/user.blade.php

@@ -0,0 +1,9 @@
+<div class="d-flex user my-2">
+    @isset($user)
+        <img class="img-fluid d-block" style="background-color: {{ $user->bg_color }};" src="{{ asset('images/avatars/' . $user->avatar) }}" alt="ava">
+        <div>
+            <div><span class="d-md-none fw-bold">{{ $role ?? 'Пользователь' }}: </span>{{ $user->sname }}</div>
+            <div class="role text-secondary">{{ $user->subscribe ?? $user?->roles()?->first()?->title }}</div>
+        </div>
+    @endisset
+</div>

+ 31 - 0
resources/views/users/edit.blade.php

@@ -0,0 +1,31 @@
+@extends('layouts.app')
+
+@section('content')
+    <div class="px-3">
+        <div class="col-xxl-6 offset-xxl-2">
+            <form action="{{ route('user.store') }}" method="post">
+                @csrf
+
+                @if($user)
+                    <input type="hidden" name="id" value="{{ $user->id }}">
+                @endif
+
+                @include('partials.input', ['name' => 'email',
+                                            'type' => 'email',
+                                            'title' => ($user && $user->email_verified_at) ? 'Email подтверждён' : 'Email',
+                                            'required' => true,
+                                            'value' => $user->email ?? '',
+                                            'disabled' => ($user && $user->email_verified_at),
+                                            ])
+                @include('partials.input', ['name' => 'name', 'title' => 'Имя', 'required' => true, 'value' => $user->name ?? ''])
+
+{{--                @include('partials.avatars', ['user' => $user])--}}
+                @include('partials.input', ['name' => 'password', 'type' => 'password', 'title' => 'Пароль'])
+
+                @include('partials.select', ['name' => 'role', 'title' => 'Роль', 'options' => getRoles(), 'value' => $user->role ?? \App\Models\Role::MANAGER])
+
+                @include('partials.submit')
+            </form>
+        </div>
+    </div>
+@endsection

+ 43 - 0
resources/views/users/index.blade.php

@@ -0,0 +1,43 @@
+@extends('layouts.app')
+
+@section('content')
+    <a href="{{ route('user.create') }}" class="btn btn-sm btn-primary">Создать</a>
+    <div class="px-md-3 px-2">
+        <div class="table">
+            <div class="row d-none d-md-flex header px-5 py-4 fw-bold">
+                <div class="col-xl-3">Имя пользователя</div>
+                <div class="col-xl-3">Email</div>
+                <div class="col-xl-3">Роль</div>
+                <div class="col-xl-3 text-end">Действия</div>
+            </div>
+            @foreach($users as $user)
+                <div class="row px-md-5 px-2 py-4 align-items-center">
+                    <div class="col-xl-3 mb-3">
+                        <span class="ps-2">{{ $user->name }}</span>
+                    </div>
+                    <div class="col-xl-3">
+                        <span class="d-md-none fw-bold">E-mail: </span>{{ $user->email }}
+                    </div>
+                    <div class="col-xl-3">
+                        <span class="d-md-none fw-bold">Роль: </span>{{ roleName($user->role) }}
+                    </div>
+                    <div class="col-xl-3 text-end">
+                        <a href="{{ route('user.show', $user->id) }}"><i class="bi bi-pencil fs-5 text-primary"></i></a>
+                        <a href="#" onclick="if(confirm('Удалить пользователя {{ $user->name }}?')) { document.getElementById('delete-user-{{ $user->id }}').submit(); } ">
+                            <i class="bi bi-trash fs-5 text-danger"></i>
+                        </a>
+                        <form action="{{ route('user.destroy', $user->id) }}" method="post" class="visually-hidden d-none" id="delete-user-{{ $user->id }}">
+                            @csrf
+                            @method('DELETE')
+                        </form>
+                    </div>
+                </div>
+            @endforeach
+        </div>
+    </div>
+
+    @if($errors->any())
+        @dump($errors)
+    @endif
+
+@endsection

+ 64 - 0
resources/views/users/profile.blade.php

@@ -0,0 +1,64 @@
+@extends('layouts.app', ['title' => 'Профиль', 'experience' => $user->experience])
+
+@section('content')
+    <div class="px-3 col-xxl-8 offset-xxl-1">
+        <form action="{{ route('profile.store') }}" method="post">
+            @csrf
+
+            @include('partials.input', ['name' => 'email', 'title' => 'E-mail', 'disabled' => true, 'value' => $user->email])
+            @include('partials.input', ['name' => 'name', 'title' => 'Имя', 'required' => true, 'value' => $user->name])
+            @include('partials.input', ['name' => 'surname', 'title' => 'Фамилия', 'value' => $user->surname])
+
+
+
+            @include('partials.avatars', ['user' => auth()->user()])
+
+            @include('partials.color', ['name' => 'bg_color', 'title' => 'Фон', 'value' => $user->bg_color ?? '#55def7'])
+
+            @if(auth()->user()->experience > $max_subscribe_experience)
+                <div class="visually-hidden">
+                    @include('partials.input', ['name' => 'e_mail', 'title' => 'E-mail', 'value' => $user->email])
+                </div>
+
+                @include('partials.input', ['name' => 'subscribe', 'title' => 'Подпись', 'value' => $user->subscribe ?? '', 'datalist' => $subscribes->pluck('caption')])
+            @else
+                @include('partials.select', ['name' => 'subscribe', 'title' => 'Подпись', 'options' => $subscribes->pluck('caption', 'caption')->toArray(), 'value' => $user->subscribe ?? ''])
+            @endif
+
+            @include('partials.input', ['name' => 'role', 'title' => 'Роли', 'disabled' => true, 'value' => implode(', ', $user->roles->pluck('title')->toArray())])
+            <div class="my-2">
+                <div class="row">
+                    <div class="col-md-4 text-md-end mt-1 fw-bold pe-3">Уведомления</div>
+                    <div class="col-md-8 mt-1 fst-italic">Выберите типы уведомлений, которые будем присылать вам на E-mail</div>
+                </div>
+                @foreach($notification_types as $type_id => $t)
+                    @include('partials.checkbox', ['name' => 'types[]', 'title' => $t, 'value' => $type_id, 'checked' => in_array($type_id, auth()->user()->notification_types->pluck('id')->toArray())])
+                @endforeach
+            </div>
+
+            <div class="row pwd-change-link mb-3">
+                <div class="offset-md-4">
+                    <a onclick="$('.pwg-change').removeClass('d-none'); $('.pwd-change-link').addClass('d-none');" class="text-info" href="#">Сменить пароль</a>
+                </div>
+            </div>
+
+            <div class="pwg-change d-none">
+                @include('partials.input', ['name' => 'current_password', 'type' => 'password', 'title' => 'Текущий пароль'])
+                @include('partials.input', ['name' => 'password', 'type' => 'password', 'title' => 'Новый пароль'])
+                @include('partials.input', ['name' => 'password_confirmation', 'type' => 'password', 'title' => 'Подтверждение пароля'])
+            </div>
+            @include('partials.submit', [''])
+            <a href="#" class="btn btn-outline-info my-3 d-inline-block d-md-none" onclick="$('#logout').submit();">Выход</a>
+        </form>
+    </div>
+@endsection
+
+@push('scripts')
+    <script type="module">
+        $('#subscribe').on('keyup', function (){
+            if($(this).val() === '') {
+                $('#dl-subscribe').attr('open', 'open');
+            }
+        });
+    </script>
+@endpush

+ 13 - 0
routes/web.php

@@ -1,5 +1,7 @@
 <?php
 
+use App\Http\Controllers\UserController;
+use App\Models\Role;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\Route;
 
@@ -10,3 +12,14 @@ Route::get('/', function () {
 Auth::routes();
 
 Route::get('/home', [App\Http\Controllers\HomeController::class, 'index'])->name('home');
+
+Route::prefix('admin')->middleware('role:' . Role::ADMIN)->group(function (){
+    Route::prefix('users')->group(function (){
+        Route::get('', [UserController::class, 'index'])->name('user.index');
+        Route::get('create', [UserController::class, 'create'])->name('user.create');
+        Route::get('{user}', [UserController::class, 'show'])->name('user.show');
+        Route::post('', [UserController::class, 'store'])->name('user.store');
+        Route::put('{user}', [UserController::class, 'update'])->name('user.update');
+        Route::delete('{user}', [UserController::class, 'destroy'])->name('user.destroy');
+    });
+});

+ 1 - 1
todo.md

@@ -1,5 +1,5 @@
 - [x] пользователи и роли
-- [ ] управление пользователями (CRUD)
+- [x] управление пользователями (CRUD)
 - [ ] каталог товаров
 - [ ] импорт товаров
 - [ ] складские остатки товаров