first&last

This commit is contained in:
sbb45
2026-03-20 15:47:17 +05:00
commit d6441abdd2
230 changed files with 20367 additions and 0 deletions
@@ -0,0 +1,39 @@
<?php
namespace App\Console\Commands;
use App\Models\ServiceRequest;
use App\Models\User;
use App\Services\NotificationService;
use Illuminate\Console\Command;
class CheckSlaRequestsCommand extends Command
{
protected $signature = 'requests:check-sla';
protected $description = 'Проверка SLA и отправка предупреждений по заявкам';
public function __construct(private readonly NotificationService $notificationService)
{
parent::__construct();
}
public function handle(): int
{
$managers = User::query()->whereIn('role', ['admin', 'manager'])->get();
ServiceRequest::query()
->with(['status', 'assignee'])
->open()
->whereNotNull('deadline_at')
->whereBetween('deadline_at', [now(), now()->addHour()])
->each(fn (ServiceRequest $request) => $this->notificationService->notifySlaWarning($request, $managers));
ServiceRequest::query()
->with(['status', 'assignee'])
->overdue()
->each(fn (ServiceRequest $request) => $this->notificationService->notifyOverdue($request, $managers));
return self::SUCCESS;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
/**
* Define the application's command schedule.
*/
protected function schedule(Schedule $schedule): void
{
$schedule->command('requests:check-sla')->everyFiveMinutes();
}
/**
* Register the commands for the application.
*/
protected function commands(): void
{
$this->load(__DIR__.'/Commands');
require base_path('routes/console.php');
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Enums;
enum EquipmentCondition: string
{
case GOOD = 'good';
case FAIR = 'fair';
case POOR = 'poor';
public function label(): string
{
return match ($this) {
self::GOOD => 'Хорошее',
self::FAIR => 'Удовлетворительное',
self::POOR => 'Плохое',
};
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Enums;
enum StatusSlug: string
{
case NEW = 'new';
case IN_PROGRESS = 'in_progress';
case WAITING = 'waiting';
case RESOLVED = 'resolved';
case CLOSED = 'closed';
case CANCELLED = 'cancelled';
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Enums;
enum UserRole: string
{
case ADMIN = 'admin';
case MANAGER = 'manager';
case TECHNICIAN = 'technician';
case CLIENT = 'client';
public function label(): string
{
return match ($this) {
self::ADMIN => 'Администратор',
self::MANAGER => 'Менеджер',
self::TECHNICIAN => 'Техник',
self::CLIENT => 'Клиент',
};
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Events;
use App\Models\ServiceRequest;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class RequestAssigned
{
use Dispatchable;
use SerializesModels;
public function __construct(
public ServiceRequest $request,
public User $actor
) {
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Events;
use App\Models\ServiceRequest;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class RequestCreated
{
use Dispatchable;
use SerializesModels;
public function __construct(
public ServiceRequest $request,
public User $actor
) {
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Events;
use App\Models\ServiceRequest;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class RequestStatusChanged
{
use Dispatchable;
use SerializesModels;
public function __construct(
public ServiceRequest $request,
public User $actor,
public int $oldStatusId,
public int $newStatusId
) {
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Throwable;
class Handler extends ExceptionHandler
{
/**
* The list of the inputs that are never flashed to the session on validation exceptions.
*
* @var array<int, string>
*/
protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];
/**
* Register the exception handling callbacks for the application.
*/
public function register(): void
{
$this->reportable(function (Throwable $e) {
//
});
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Exports;
use Symfony\Component\HttpFoundation\StreamedResponse;
class CsvExport
{
/**
* @param array<int, string> $headers
* @param array<int, array<int, mixed>> $rows
*/
public static function download(string $filename, array $headers, array $rows): StreamedResponse
{
return response()->streamDownload(function () use ($headers, $rows): void {
$handle = fopen('php://output', 'wb');
if ($handle === false) {
return;
}
// UTF-8 BOM improves Russian text rendering in Excel.
fwrite($handle, "\xEF\xBB\xBF");
fputcsv($handle, self::normalizeRow($headers), ';');
foreach ($rows as $row) {
fputcsv($handle, self::normalizeRow($row), ';');
}
fclose($handle);
}, $filename, [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
/**
* @param array<int, mixed> $row
* @return array<int, mixed>
*/
private static function normalizeRow(array $row): array
{
return array_map(static function (mixed $value): mixed {
if ($value === null) {
return '';
}
if (is_string($value)) {
return str_replace(["\r\n", "\r"], "\n", $value);
}
if (is_bool($value)) {
return $value ? '1' : '0';
}
return $value;
}, $row);
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers;
use App\Models\ServiceRequest;
use App\Models\RequestAttachment;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
class AttachmentController extends Controller
{
public function download(ServiceRequest $request, RequestAttachment $attachment): Response
{
abort_unless($attachment->request_id === $request->id, 404);
Gate::authorize('view', $request);
if (!Storage::disk('local')->exists($attachment->path)) {
abort(404, 'Файл не найден.');
}
return response()->download(
Storage::disk('local')->path($attachment->path),
$attachment->filename
);
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
class ForgotPasswordController extends Controller
{
public function create()
{
return view('auth.forgot-password');
}
public function store(Request $request)
{
$request->validate(['email' => ['required', 'email']]);
$status = Password::sendResetLink($request->only('email'));
return $status === Password::RESET_LINK_SENT
? back()->with('status', __($status))
: back()->withErrors(['email' => __($status)]);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
class ResetPasswordController extends Controller
{
public function create(Request $request)
{
return view('auth.reset-password', ['request' => $request]);
}
public function store(Request $request)
{
$request->validate([
'token' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', 'min:8'],
]);
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function ($user) use ($request): void {
$user->forceFill([
'password' => Hash::make($request->string('password')->toString()),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
return $status === Password::PASSWORD_RESET
? redirect()->route('login')->with('status', __($status))
: back()->withErrors(['email' => [__($status)]]);
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
class Controller extends BaseController
{
use AuthorizesRequests, ValidatesRequests;
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array<int, class-string|string>
*/
protected $middleware = [
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
];
/**
* The application's route middleware groups.
*
* @var array<string, array<int, class-string|string>>
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
/**
* The application's middleware aliases.
*
* Aliases may be used instead of class names to conveniently assign middleware to routes and groups.
*
* @var array<string, class-string|string>
*/
protected $middlewareAliases = [
'active_user' => \App\Http\Middleware\EnsureUserIsActive::class,
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class,
'signed' => \App\Http\Middleware\ValidateSignature::class,
'role' => \App\Http\Middleware\RoleMiddleware::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
];
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Auth\Middleware\Authenticate as Middleware;
use Illuminate\Http\Request;
class Authenticate extends Middleware
{
/**
* Get the path the user should be redirected to when they are not authenticated.
*/
protected function redirectTo(Request $request): ?string
{
return $request->expectsJson() ? null : route('login');
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;
class EncryptCookies extends Middleware
{
/**
* The names of the cookies that should not be encrypted.
*
* @var array<int, string>
*/
protected $except = [
//
];
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserIsActive
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user && !$user->is_active) {
auth()->logout();
return redirect()->route('login')->withErrors([
'email' => 'Ваш аккаунт отключен. Обратитесь к администратору.',
]);
}
return $next($request);
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance as Middleware;
class PreventRequestsDuringMaintenance extends Middleware
{
/**
* The URIs that should be reachable while maintenance mode is enabled.
*
* @var array<int, string>
*/
protected $except = [
//
];
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Middleware;
use App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class RedirectIfAuthenticated
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next, string ...$guards): Response
{
$guards = empty($guards) ? [null] : $guards;
foreach ($guards as $guard) {
if (Auth::guard($guard)->check()) {
return redirect(RouteServiceProvider::HOME);
}
}
return $next($request);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RoleMiddleware
{
public function handle(Request $request, Closure $next, string ...$roles): Response
{
$user = $request->user();
if (!$user) {
abort(401);
}
if (!in_array($user->role->value, $roles, true)) {
abort(403, 'Недостаточно прав для доступа к разделу.');
}
return $next($request);
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\TrimStrings as Middleware;
class TrimStrings extends Middleware
{
/**
* The names of the attributes that should not be trimmed.
*
* @var array<int, string>
*/
protected $except = [
'current_password',
'password',
'password_confirmation',
];
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustHosts as Middleware;
class TrustHosts extends Middleware
{
/**
* Get the host patterns that should be trusted.
*
* @return array<int, string|null>
*/
public function hosts(): array
{
return [
$this->allSubdomainsOfApplicationUrl(),
];
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustProxies as Middleware;
use Illuminate\Http\Request;
class TrustProxies extends Middleware
{
/**
* The trusted proxies for this application.
*
* @var array<int, string>|string|null
*/
protected $proxies;
/**
* The headers that should be used to detect proxies.
*
* @var int
*/
protected $headers =
Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO |
Request::HEADER_X_FORWARDED_AWS_ELB;
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Routing\Middleware\ValidateSignature as Middleware;
class ValidateSignature extends Middleware
{
/**
* The names of the query string parameters that should be ignored.
*
* @var array<int, string>
*/
protected $except = [
// 'fbclid',
// 'utm_campaign',
// 'utm_content',
// 'utm_medium',
// 'utm_source',
// 'utm_term',
];
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array<int, string>
*/
protected $except = [
//
];
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Foundation\Http\FormRequest;
class LoginRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'email' => ['required', 'email'],
'password' => ['required', 'string'],
'remember' => ['nullable', 'boolean'],
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Directory;
use Illuminate\Foundation\Http\FormRequest;
class DepartmentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$department = $this->route('department');
return [
'name' => ['required', 'string', 'max:255', 'unique:departments,name'.($department ? ','.$department->id : '')],
'description' => ['nullable', 'string'],
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Directory;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class EquipmentCategoryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$category = $this->route('equipment_category');
return [
'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', Rule::unique('equipment_categories', 'slug')->ignore($category)],
'description' => ['nullable', 'string'],
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Directory;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class EquipmentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$equipment = $this->route('equipment');
return [
'name' => ['required', 'string', 'max:255'],
'serial_number' => ['required', 'string', Rule::unique('equipment', 'serial_number')->ignore($equipment)],
'inventory_number' => ['required', 'string', Rule::unique('equipment', 'inventory_number')->ignore($equipment)],
'category_id' => ['required', 'exists:equipment_categories,id'],
'client_id' => ['nullable', 'exists:users,id'],
'purchase_date' => ['nullable', 'date'],
'warranty_until' => ['nullable', 'date'],
'condition' => ['required', Rule::in(['good', 'fair', 'poor'])],
'notes' => ['nullable', 'string'],
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Directory;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class PriorityRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$priority = $this->route('priority');
return [
'name' => ['required', 'string', 'max:255'],
'color_hex' => ['required', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'level' => ['required', 'integer', 'between:1,4', Rule::unique('priorities', 'level')->ignore($priority)],
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Directory;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class RequestTypeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$type = $this->route('request_type');
return [
'name' => ['required', 'string', 'max:255', Rule::unique('request_types', 'name')->ignore($type)],
'description' => ['nullable', 'string'],
'sla_hours' => ['required', 'integer', 'min:1', 'max:720'],
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\Directory;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StatusRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$status = $this->route('status');
return [
'name' => ['required', 'string', 'max:255'],
'color_hex' => ['required', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'slug' => ['required', 'string', Rule::unique('statuses', 'slug')->ignore($status)],
'is_final' => ['nullable', 'boolean'],
'sort_order' => ['required', 'integer', 'min:1', 'max:100'],
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Profile;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateProfileRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', Rule::unique('users', 'email')->ignore($this->user()?->id)],
'phone' => ['nullable', 'string', 'max:32'],
'password' => ['nullable', 'string', 'confirmed', 'min:8'],
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Request;
use Illuminate\Foundation\Http\FormRequest;
class RateServiceRequestRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'client_rating' => ['required', 'integer', 'between:1,5'],
'client_comment' => ['nullable', 'string', 'max:2000'],
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Request;
use Illuminate\Foundation\Http\FormRequest;
class StoreCommentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'body' => ['required', 'string', 'max:5000'],
'is_internal' => ['nullable', 'boolean'],
];
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests\Request;
use Illuminate\Foundation\Http\FormRequest;
class StoreServiceRequestRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'description' => ['required', 'string'],
'type_id' => ['required', 'exists:request_types,id'],
'priority_id' => ['required', 'exists:priorities,id'],
'status_id' => ['nullable', 'exists:statuses,id'],
'client_id' => ['required', 'exists:users,id'],
'assigned_to' => ['nullable', 'exists:users,id'],
'equipment_id' => ['nullable', 'exists:equipment,id'],
'location' => ['nullable', 'string', 'max:255'],
'planned_at' => ['nullable', 'date'],
'deadline_at' => ['nullable', 'date'],
'resolution_notes' => ['nullable', 'string'],
'attachments' => ['nullable', 'array', 'max:5'],
'attachments.*' => ['file', 'max:10240'],
];
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Http\Requests\Request;
class UpdateServiceRequestRequest extends StoreServiceRequestRequest
{
public function rules(): array
{
$rules = parent::rules();
$rules['title'][0] = 'sometimes';
return $rules;
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\System;
use Illuminate\Foundation\Http\FormRequest;
class UpdateSettingsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'company_name' => ['required', 'string', 'max:255'],
'timezone' => ['required', 'timezone'],
'default_sla_hours' => ['required', 'integer', 'min:1', 'max:720'],
'maintenance_mode' => ['nullable', 'boolean'],
'notify_assigned' => ['nullable', 'boolean'],
'notify_status_changed' => ['nullable', 'boolean'],
'notify_comment_added' => ['nullable', 'boolean'],
'notify_overdue' => ['nullable', 'boolean'],
'notify_sla_warning' => ['nullable', 'boolean'],
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreUserRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
'role' => ['required', Rule::in(['admin', 'manager', 'technician', 'client'])],
'department_id' => ['nullable', 'exists:departments,id'],
'phone' => ['nullable', 'string', 'max:32'],
'is_active' => ['nullable', 'boolean'],
];
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateUserRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$user = $this->route('user');
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user)],
'password' => ['nullable', 'string', 'min:8', 'confirmed'],
'role' => ['required', Rule::in(['admin', 'manager', 'technician', 'client'])],
'department_id' => ['nullable', 'exists:departments,id'],
'phone' => ['nullable', 'string', 'max:32'],
'is_active' => ['nullable', 'boolean'],
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ActivityLogResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'user' => $this->user?->name,
'action' => $this->action,
'entity' => class_basename($this->model_type),
'model_id' => $this->model_id,
'description' => $this->description,
'ip_address' => $this->ip_address,
'created_at' => $this->created_at?->toDateTimeString(),
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ServiceRequestResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'ticket_number' => $this->ticket_number,
'title' => $this->title,
'description' => $this->description,
'type' => $this->type?->name,
'priority' => $this->priority?->name,
'priority_color' => $this->priority?->color_hex,
'status' => $this->status?->name,
'status_slug' => $this->status?->slug,
'status_color' => $this->status?->color_hex,
'client' => $this->client?->name,
'assignee' => $this->assignee?->name,
'deadline_at' => $this->deadline_at?->toDateTimeString(),
'created_at' => $this->created_at?->toDateTimeString(),
'updated_at' => $this->updated_at?->toDateTimeString(),
];
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'role' => $this->role?->value,
'role_label' => $this->role?->label(),
'phone' => $this->phone,
'department' => $this->department?->name,
'is_active' => $this->is_active,
'last_login_at' => $this->last_login_at?->toDateTimeString(),
'created_at' => $this->created_at?->toDateTimeString(),
];
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Listeners;
use App\Events\RequestAssigned;
use App\Services\ActivityLogService;
class LogRequestAssigned
{
public function __construct(private readonly ActivityLogService $activityLogService)
{
}
public function handle(RequestAssigned $event): void
{
$assignee = $event->request->assignee?->name ?? 'не назначен';
$this->activityLogService->log(
$event->actor,
$event->request,
$event->request->id,
'request_assigned',
"Заявка {$event->request->ticket_number} назначена на {$assignee}"
);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Listeners;
use App\Events\RequestCreated;
use App\Services\ActivityLogService;
class LogRequestCreated
{
public function __construct(private readonly ActivityLogService $activityLogService)
{
}
public function handle(RequestCreated $event): void
{
$this->activityLogService->log(
$event->actor,
$event->request,
$event->request->id,
'request_created',
"Создана заявка {$event->request->ticket_number}"
);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Listeners;
use App\Events\RequestStatusChanged;
use App\Services\ActivityLogService;
class LogRequestStatusChanged
{
public function __construct(private readonly ActivityLogService $activityLogService)
{
}
public function handle(RequestStatusChanged $event): void
{
$this->activityLogService->log(
$event->actor,
$event->request,
$event->request->id,
'request_status_changed',
"Статус заявки {$event->request->ticket_number} изменен"
);
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Listeners;
use App\Events\RequestAssigned;
use App\Services\NotificationService;
use Illuminate\Contracts\Queue\ShouldQueue;
class SendAssignedNotification implements ShouldQueue
{
public function __construct(private readonly NotificationService $notificationService)
{
}
public function handle(RequestAssigned $event): void
{
$this->notificationService->notifyRequestAssigned($event->request);
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Listeners;
use App\Events\RequestStatusChanged;
use App\Services\NotificationService;
use Illuminate\Contracts\Queue\ShouldQueue;
class SendStatusChangedNotification implements ShouldQueue
{
public function __construct(private readonly NotificationService $notificationService)
{
}
public function handle(RequestStatusChanged $event): void
{
$this->notificationService->notifyStatusChanged($event->request);
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Livewire\Auth;
use App\Http\Requests\Auth\LoginRequest;
use App\Services\ActivityLogService;
use App\Support\RoleRedirect;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.guest')]
class LoginPage extends Component
{
public string $email = '';
public string $password = '';
public bool $remember = false;
public bool $showPassword = false;
public function login(ActivityLogService $activityLogService)
{
$this->validate((new LoginRequest())->rules());
$key = strtolower($this->email).'|'.request()->ip();
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages([
'email' => 'Слишком много попыток входа. Попробуйте позже.',
]);
}
if (!Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) {
RateLimiter::hit($key, 60);
throw ValidationException::withMessages([
'email' => 'Неверный email или пароль.',
]);
}
RateLimiter::clear($key);
request()->session()->regenerate();
$user = auth()->user();
$user->forceFill(['last_login_at' => now()])->save();
$activityLogService->log($user, $user, $user->id, 'login', 'Вход в систему', request()->ip());
return redirect()->intended(route(RoleRedirect::routeFor($user)));
}
public function render()
{
return view('livewire.auth.login-page')
->title('Вход в систему');
}
}
+125
View File
@@ -0,0 +1,125 @@
<?php
namespace App\Livewire\Dashboard;
use App\Models\ActivityLog;
use App\Models\ServiceRequest;
use App\Models\Status;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class DashboardPage extends Component
{
public int $openToday = 0;
public int $overdue = 0;
public float $avgResolutionHours = 0;
public array $statusChart = [];
public array $priorityChart = [];
public array $topTechnicians = [];
public array $recentActivities = [];
public array $upcomingTasks = [];
public function mount(): void
{
abort_unless(auth()->user()->isAdmin() || auth()->user()->isManager(), 403);
$this->refreshData();
}
public function refreshData(): void
{
$this->openToday = ServiceRequest::query()
->open()
->whereDate('created_at', now()->toDateString())
->count();
$this->overdue = ServiceRequest::query()->overdue()->count();
$this->avgResolutionHours = (float) (ServiceRequest::query()
->whereNotNull('completed_at')
->whereDate('completed_at', '>=', now()->subDays(30))
->selectRaw('AVG(TIMESTAMPDIFF(HOUR, created_at, completed_at)) as avg_hours')
->value('avg_hours') ?? 0);
$this->statusChart = Status::query()
->orderBy('sort_order')
->get()
->map(fn (Status $status) => [
'label' => $status->name,
'value' => ServiceRequest::query()->where('status_id', $status->id)->count(),
'color' => $status->color_hex,
])
->toArray();
$this->priorityChart = ServiceRequest::query()
->join('priorities', 'priorities.id', '=', 'requests.priority_id')
->selectRaw('priorities.name as label, priorities.color_hex as color, COUNT(requests.id) as value')
->groupBy('priorities.id', 'priorities.name', 'priorities.color_hex')
->orderByRaw('MIN(priorities.level)')
->get()
->map(fn ($row) => [
'label' => $row->label,
'value' => (int) $row->value,
'color' => $row->color,
])
->toArray();
$this->topTechnicians = User::query()
->where('role', 'technician')
->withCount([
'assignedRequests as closed_requests_count' => fn (Builder $query) => $query->whereHas('status', fn (Builder $status) => $status->where('slug', 'closed')),
])
->orderByDesc('closed_requests_count')
->limit(5)
->get()
->map(fn (User $user) => [
'name' => $user->name,
'closed_requests_count' => (int) $user->closed_requests_count,
])
->toArray();
$this->recentActivities = ActivityLog::query()
->with('user')
->latest('created_at')
->limit(10)
->get()
->map(fn (ActivityLog $log) => [
'user' => $log->user?->name ?? 'Система',
'action' => $log->action,
'description' => $log->description,
'created_at' => $log->created_at?->format('d.m.Y H:i'),
])
->toArray();
$this->upcomingTasks = ServiceRequest::query()
->with(['assignee', 'status'])
->whereNotNull('planned_at')
->whereBetween('planned_at', [now(), now()->addDays(7)])
->orderBy('planned_at')
->limit(8)
->get()
->map(fn (ServiceRequest $request) => [
'ticket' => $request->ticket_number,
'title' => $request->title,
'planned_at' => $request->planned_at?->format('d.m.Y H:i'),
'assignee' => $request->assignee?->name ?? 'Не назначен',
])
->toArray();
$this->dispatch('dashboard-charts-updated', status: $this->statusChart, priority: $this->priorityChart);
}
public function render()
{
return view('livewire.dashboard.dashboard-page')->title('Дашборд');
}
}
@@ -0,0 +1,65 @@
<?php
namespace App\Livewire\Directories;
use App\Models\Department;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class DepartmentsPage extends Component
{
public string $name = '';
public string $description = '';
public ?int $editingId = null;
public function mount(): void
{
abort_unless(auth()->user()->isAdmin(), 403);
}
public function edit(int $id): void
{
$model = Department::query()->findOrFail($id);
$this->editingId = $model->id;
$this->name = $model->name;
$this->description = (string) $model->description;
}
public function save(): void
{
$nameRule = Rule::unique('departments', 'name');
if ($this->editingId) {
$nameRule = $nameRule->ignore($this->editingId);
}
$this->validate([
'name' => ['required', 'string', 'max:255', $nameRule],
'description' => ['nullable', 'string'],
]);
Department::query()->updateOrCreate(
['id' => $this->editingId],
['name' => $this->name, 'description' => $this->description]
);
$this->reset(['name', 'description', 'editingId']);
session()->flash('success', 'Отдел сохранен.');
}
public function delete(int $id): void
{
Department::query()->findOrFail($id)->delete();
session()->flash('success', 'Отдел удален.');
}
public function render()
{
return view('livewire.directories.departments-page', [
'items' => Department::query()->orderBy('name')->get(),
])->title('Справочник: Отделы');
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Livewire\Directories;
use App\Models\EquipmentCategory;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class EquipmentCategoriesPage extends Component
{
public string $name = '';
public string $description = '';
public ?int $editingId = null;
public function mount(): void
{
abort_unless(auth()->user()->isAdmin(), 403);
}
public function edit(int $id): void
{
$model = EquipmentCategory::query()->findOrFail($id);
$this->editingId = $model->id;
$this->name = $model->name;
$this->description = (string) $model->description;
}
public function save(): void
{
$this->validate([
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
]);
$slug = $this->generateUniqueSlug($this->name);
EquipmentCategory::query()->updateOrCreate(
['id' => $this->editingId],
['name' => $this->name, 'slug' => $slug, 'description' => $this->description]
);
$this->reset(['name', 'description', 'editingId']);
session()->flash('success', 'Категория сохранена.');
}
public function delete(int $id): void
{
EquipmentCategory::query()->findOrFail($id)->delete();
session()->flash('success', 'Категория удалена.');
}
private function generateUniqueSlug(string $name): string
{
$baseSlug = Str::slug($name);
if ($baseSlug === '') {
$baseSlug = 'category';
}
$slug = $baseSlug;
$suffix = 2;
while (
EquipmentCategory::query()
->where('slug', $slug)
->when($this->editingId, fn ($query) => $query->where('id', '!=', $this->editingId))
->exists()
) {
$slug = $baseSlug.'-'.$suffix;
$suffix++;
}
return $slug;
}
public function render()
{
return view('livewire.directories.equipment-categories-page', [
'items' => EquipmentCategory::query()->orderBy('name')->get(),
])->title('Справочник: Категории оборудования');
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace App\Livewire\Directories;
use App\Models\Equipment;
use App\Models\EquipmentCategory;
use App\Models\User;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithPagination;
use Illuminate\Validation\Rule;
#[Layout('layouts.app')]
class EquipmentPage extends Component
{
use WithPagination;
public string $name = '';
public string $serialNumber = '';
public string $inventoryNumber = '';
public string $categoryId = '';
public string $clientId = '';
public string $purchaseDate = '';
public string $warrantyUntil = '';
public string $condition = 'good';
public string $notes = '';
public ?int $editingId = null;
public function mount(): void
{
abort_unless(auth()->user()->isAdmin(), 403);
}
public function edit(int $id): void
{
$model = Equipment::query()->findOrFail($id);
$this->editingId = $model->id;
$this->name = $model->name;
$this->serialNumber = $model->serial_number;
$this->inventoryNumber = $model->inventory_number;
$this->categoryId = (string) $model->category_id;
$this->clientId = (string) $model->client_id;
$this->purchaseDate = (string) $model->purchase_date;
$this->warrantyUntil = (string) $model->warranty_until;
$this->condition = $model->condition->value;
$this->notes = (string) $model->notes;
}
public function save(): void
{
$serialRule = Rule::unique('equipment', 'serial_number');
$inventoryRule = Rule::unique('equipment', 'inventory_number');
if ($this->editingId) {
$serialRule = $serialRule->ignore($this->editingId);
$inventoryRule = $inventoryRule->ignore($this->editingId);
}
$this->validate([
'name' => ['required', 'string', 'max:255'],
'serialNumber' => ['required', 'string', $serialRule],
'inventoryNumber' => ['required', 'string', $inventoryRule],
'categoryId' => ['required', 'exists:equipment_categories,id'],
'clientId' => ['nullable', 'exists:users,id'],
'purchaseDate' => ['nullable', 'date'],
'warrantyUntil' => ['nullable', 'date'],
'condition' => ['required', Rule::in(['good', 'fair', 'poor'])],
'notes' => ['nullable', 'string'],
]);
Equipment::query()->updateOrCreate(
['id' => $this->editingId],
[
'name' => $this->name,
'serial_number' => $this->serialNumber,
'inventory_number' => $this->inventoryNumber,
'category_id' => $this->categoryId,
'client_id' => $this->clientId ?: null,
'purchase_date' => $this->purchaseDate ?: null,
'warranty_until' => $this->warrantyUntil ?: null,
'condition' => $this->condition,
'notes' => $this->notes,
]
);
$this->reset(['name', 'serialNumber', 'inventoryNumber', 'categoryId', 'clientId', 'purchaseDate', 'warrantyUntil', 'notes', 'editingId']);
$this->condition = 'good';
session()->flash('success', 'Оборудование сохранено.');
}
public function delete(int $id): void
{
Equipment::query()->findOrFail($id)->delete();
session()->flash('success', 'Оборудование удалено.');
}
public function render()
{
return view('livewire.directories.equipment-page', [
'items' => Equipment::query()->with(['category', 'client'])->latest()->paginate(15),
'categories' => EquipmentCategory::query()->orderBy('name')->get(),
'clients' => User::query()->where('role', 'client')->orderBy('name')->get(),
])->title('Справочник: Оборудование');
}
}
@@ -0,0 +1,71 @@
<?php
namespace App\Livewire\Directories;
use App\Models\Priority;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class PrioritiesPage extends Component
{
public string $name = '';
public string $colorHex = '#3B82F6';
public int $level = 1;
public ?int $editingId = null;
public function mount(): void
{
abort_unless(auth()->user()->isAdmin(), 403);
}
public function edit(int $id): void
{
$model = Priority::query()->findOrFail($id);
$this->editingId = $model->id;
$this->name = $model->name;
$this->colorHex = $model->color_hex;
$this->level = $model->level;
}
public function save(): void
{
$levelRule = Rule::unique('priorities', 'level');
if ($this->editingId) {
$levelRule = $levelRule->ignore($this->editingId);
}
$this->validate([
'name' => ['required', 'string', 'max:255'],
'colorHex' => ['required', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'level' => ['required', 'integer', 'between:1,4', $levelRule],
]);
Priority::query()->updateOrCreate(
['id' => $this->editingId],
['name' => $this->name, 'color_hex' => $this->colorHex, 'level' => $this->level]
);
$this->reset(['name', 'editingId']);
$this->colorHex = '#3B82F6';
$this->level = 1;
session()->flash('success', 'Приоритет сохранен.');
}
public function delete(int $id): void
{
Priority::query()->findOrFail($id)->delete();
session()->flash('success', 'Приоритет удален.');
}
public function render()
{
return view('livewire.directories.priorities-page', [
'items' => Priority::query()->orderBy('level')->get(),
])->title('Справочник: Приоритеты');
}
}
@@ -0,0 +1,70 @@
<?php
namespace App\Livewire\Directories;
use App\Models\RequestType;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class RequestTypesPage extends Component
{
public string $name = '';
public string $description = '';
public int $slaHours = 24;
public ?int $editingId = null;
public function mount(): void
{
abort_unless(auth()->user()->isAdmin(), 403);
}
public function edit(int $id): void
{
$model = RequestType::query()->findOrFail($id);
$this->editingId = $model->id;
$this->name = $model->name;
$this->description = (string) $model->description;
$this->slaHours = $model->sla_hours;
}
public function save(): void
{
$nameRule = Rule::unique('request_types', 'name');
if ($this->editingId) {
$nameRule = $nameRule->ignore($this->editingId);
}
$this->validate([
'name' => ['required', 'string', 'max:255', $nameRule],
'description' => ['nullable', 'string'],
'slaHours' => ['required', 'integer', 'min:1', 'max:720'],
]);
RequestType::query()->updateOrCreate(
['id' => $this->editingId],
['name' => $this->name, 'description' => $this->description, 'sla_hours' => $this->slaHours]
);
$this->reset(['name', 'description', 'editingId']);
$this->slaHours = 24;
session()->flash('success', 'Тип заявки сохранен.');
}
public function delete(int $id): void
{
RequestType::query()->findOrFail($id)->delete();
session()->flash('success', 'Тип заявки удален.');
}
public function render()
{
return view('livewire.directories.request-types-page', [
'items' => RequestType::query()->orderBy('name')->get(),
])->title('Справочник: Типы заявок');
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
namespace App\Livewire\Directories;
use App\Models\Status;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class StatusesPage extends Component
{
public string $name = '';
public string $colorHex = '#3B82F6';
public string $slug = '';
public bool $isFinal = false;
public int $sortOrder = 1;
public ?int $editingId = null;
public function mount(): void
{
abort_unless(auth()->user()->isAdmin(), 403);
}
public function edit(int $id): void
{
$model = Status::query()->findOrFail($id);
$this->editingId = $model->id;
$this->name = $model->name;
$this->colorHex = $model->color_hex;
$this->slug = $model->slug;
$this->isFinal = $model->is_final;
$this->sortOrder = $model->sort_order;
}
public function save(): void
{
$slugRule = Rule::unique('statuses', 'slug');
if ($this->editingId) {
$slugRule = $slugRule->ignore($this->editingId);
}
$this->validate([
'name' => ['required', 'string', 'max:255'],
'colorHex' => ['required', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'slug' => ['required', 'string', $slugRule],
'isFinal' => ['boolean'],
'sortOrder' => ['required', 'integer', 'min:1', 'max:100'],
]);
Status::query()->updateOrCreate(
['id' => $this->editingId],
[
'name' => $this->name,
'color_hex' => $this->colorHex,
'slug' => $this->slug,
'is_final' => $this->isFinal,
'sort_order' => $this->sortOrder,
]
);
$this->reset(['name', 'slug', 'editingId']);
$this->colorHex = '#3B82F6';
$this->isFinal = false;
$this->sortOrder = 1;
session()->flash('success', 'Статус сохранен.');
}
public function delete(int $id): void
{
$status = Status::query()->findOrFail($id);
if ($status->requests()->exists()) {
session()->flash('error', 'Нельзя удалить статус, который используется в заявках.');
return;
}
$status->delete();
session()->flash('success', 'Статус удален.');
}
public function render()
{
return view('livewire.directories.statuses-page', [
'items' => Status::query()->orderBy('sort_order')->get(),
])->title('Справочник: Статусы');
}
}
@@ -0,0 +1,69 @@
<?php
namespace App\Livewire\MyRequests;
use App\Enums\StatusSlug;
use App\Models\Equipment;
use App\Models\Priority;
use App\Models\RequestType;
use App\Models\Status;
use App\Services\RequestService;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class MyRequestFormPage extends Component
{
public string $title = '';
public string $description = '';
public string $typeId = '';
public string $equipmentId = '';
public string $priorityId = '';
public function mount(): void
{
abort_unless(auth()->user()->isClient(), 403);
$this->priorityId = (string) Priority::query()->where('level', 2)->value('id');
}
public function save(RequestService $requestService)
{
$this->validate([
'title' => ['required', 'string', 'max:255'],
'description' => ['required', 'string'],
'typeId' => ['required', 'exists:request_types,id'],
'priorityId' => ['required', 'exists:priorities,id'],
'equipmentId' => ['nullable', 'exists:equipment,id'],
]);
$statusId = (string) Status::query()->where('slug', StatusSlug::NEW->value)->value('id');
$request = $requestService->create([
'title' => $this->title,
'description' => $this->description,
'type_id' => $this->typeId,
'priority_id' => $this->priorityId,
'status_id' => $statusId,
'client_id' => auth()->id(),
'equipment_id' => $this->equipmentId ?: null,
'assigned_to' => null,
], auth()->user());
session()->flash('success', 'Заявка создана.');
return redirect()->route('my-requests.show', $request);
}
public function render()
{
return view('livewire.my-requests.my-request-form-page', [
'types' => RequestType::query()->orderBy('name')->get(),
'equipmentItems' => Equipment::query()->where('client_id', auth()->id())->orderBy('name')->get(),
'priorities' => Priority::query()->orderBy('level')->get(),
])->title('Создание заявки');
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Livewire\MyRequests;
use App\Models\ServiceRequest;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.app')]
class MyRequestsIndexPage extends Component
{
use WithPagination;
public string $search = '';
public function render()
{
$requests = ServiceRequest::query()
->with(['status', 'priority', 'type'])
->where('client_id', auth()->id())
->when($this->search !== '', function ($query): void {
$query->where(function ($q): void {
$q->where('ticket_number', 'like', "%{$this->search}%")
->orWhere('title', 'like', "%{$this->search}%");
});
})
->latest()
->paginate(15);
return view('livewire.my-requests.my-requests-index-page', [
'requests' => $requests,
])->title('Мои заявки');
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Livewire\Notifications;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.app')]
class NotificationsPage extends Component
{
use WithPagination;
public function markAllAsRead(): void
{
auth()->user()?->unreadNotifications->markAsRead();
session()->flash('success', 'Все уведомления помечены как прочитанные.');
}
public function markAsRead(string $id): void
{
$notification = auth()->user()?->notifications()->findOrFail($id);
$notification->markAsRead();
}
public function render()
{
$notifications = auth()->user()
?->notifications()
->latest()
->paginate(15);
return view('livewire.notifications.notifications-page', [
'notifications' => $notifications,
])->title('Уведомления');
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Livewire\Profile;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class ProfilePage extends Component
{
public string $name = '';
public string $email = '';
public string $phone = '';
public string $password = '';
public string $password_confirmation = '';
public function mount(): void
{
$user = auth()->user();
$this->name = $user->name;
$this->email = $user->email;
$this->phone = (string) $user->phone;
}
public function save(): void
{
$this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', Rule::unique('users', 'email')->ignore(auth()->id())],
'phone' => ['nullable', 'string', 'max:32'],
'password' => ['nullable', 'string', 'same:password_confirmation', 'min:8'],
]);
$user = auth()->user();
$payload = [
'name' => $this->name,
'email' => $this->email,
'phone' => $this->phone,
];
if ($this->password !== '') {
$payload['password'] = Hash::make($this->password);
}
$user->update($payload);
session()->flash('success', 'Профиль обновлен.');
}
public function render()
{
return view('livewire.profile.profile-page')->title('Профиль');
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Livewire\Reports;
use App\Services\ReportService;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class EquipmentReportPage extends Component
{
public string $dateFrom = '';
public string $dateTo = '';
public function mount(): void
{
abort_unless(auth()->user()->isAdmin() || auth()->user()->isManager(), 403);
}
public function render(ReportService $reportService)
{
$rows = $reportService->equipmentFailureFrequency([
'date_from' => $this->dateFrom ?: null,
'date_to' => $this->dateTo ?: null,
]);
return view('livewire.reports.equipment-report-page', [
'rows' => $rows,
])->title('Отчет: Оборудование');
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Livewire\Reports;
use App\Models\Status;
use App\Models\RequestType;
use App\Models\User;
use App\Services\ReportService;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class GeneralReportPage extends Component
{
public string $dateFrom = '';
public string $dateTo = '';
public string $technicianId = '';
public string $typeId = '';
public string $statusId = '';
public function mount(): void
{
abort_unless(auth()->user()->isAdmin() || auth()->user()->isManager(), 403);
}
public function render(ReportService $reportService)
{
$filters = [
'date_from' => $this->dateFrom ?: null,
'date_to' => $this->dateTo ?: null,
'technician_id' => $this->technicianId ?: null,
'type_id' => $this->typeId ?: null,
'status_id' => $this->statusId ?: null,
];
$rows = $reportService->requestBaseQuery($filters)->latest()->limit(200)->get();
$statusChart = $reportService->requestsByStatus($filters);
$this->dispatch('general-report-chart-updated', chart: $statusChart->values()->toArray());
return view('livewire.reports.general-report-page', [
'rows' => $rows,
'statusChart' => $statusChart,
'technicians' => User::query()->where('role', 'technician')->orderBy('name')->get(),
'types' => RequestType::query()->orderBy('name')->get(),
'statuses' => Status::query()->orderBy('sort_order')->get(),
])->title('Отчет: Общий');
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Livewire\Reports;
use App\Services\ReportService;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class SlaReportPage extends Component
{
public string $dateFrom = '';
public string $dateTo = '';
public function mount(): void
{
abort_unless(auth()->user()->isAdmin() || auth()->user()->isManager(), 403);
}
public function render(ReportService $reportService)
{
$rows = $reportService->slaCompliance([
'date_from' => $this->dateFrom ?: null,
'date_to' => $this->dateTo ?: null,
]);
return view('livewire.reports.sla-report-page', [
'rows' => $rows,
])->title('Отчет: SLA');
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Livewire\Reports;
use App\Exports\CsvExport;
use App\Services\ReportService;
use Barryvdh\DomPDF\Facade\Pdf;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class TechniciansReportPage extends Component
{
public string $dateFrom = '';
public string $dateTo = '';
public function mount(): void
{
abort_unless(auth()->user()->isAdmin() || auth()->user()->isManager(), 403);
}
public function exportCsv(ReportService $reportService)
{
$rows = $reportService->technicianPerformance([
'date_from' => $this->dateFrom ?: null,
'date_to' => $this->dateTo ?: null,
])->values()->all();
return CsvExport::download('technicians-report.csv', [
'Техник', 'Назначено', 'Решено', 'Среднее время (ч)', 'Рейтинг', 'Просрочено',
], array_map(fn ($item) => [
$item['technician'],
$item['assigned'],
$item['resolved'],
$item['avg_resolution_hours'],
$item['rating'],
$item['overdue'],
], $rows));
}
public function exportPdf(ReportService $reportService)
{
$rows = $reportService->technicianPerformance([
'date_from' => $this->dateFrom ?: null,
'date_to' => $this->dateTo ?: null,
])->values()->all();
$pdf = Pdf::loadView('exports.technicians-report-pdf', [
'rows' => $rows,
'generatedAt' => now()->format('d.m.Y H:i'),
]);
return response()->streamDownload(fn () => print($pdf->output()), 'technicians-report.pdf');
}
public function render(ReportService $reportService)
{
$rows = $reportService->technicianPerformance([
'date_from' => $this->dateFrom ?: null,
'date_to' => $this->dateTo ?: null,
]);
return view('livewire.reports.technicians-report-page', [
'rows' => $rows,
])->title('Отчет: Эффективность техников');
}
}
+172
View File
@@ -0,0 +1,172 @@
<?php
namespace App\Livewire\Requests;
use App\Enums\StatusSlug;
use App\Models\Equipment;
use App\Models\Priority;
use App\Models\RequestType;
use App\Models\ServiceRequest;
use App\Models\Status;
use App\Models\User;
use App\Services\RequestService;
use Illuminate\Support\Carbon;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\WithFileUploads;
#[Layout('layouts.app')]
class RequestFormPage extends Component
{
use WithFileUploads;
public ?ServiceRequest $serviceRequest = null;
public ?int $serviceRequestId = null;
public string $title = '';
public string $description = '';
public string $typeId = '';
public string $priorityId = '';
public string $statusId = '';
public string $clientId = '';
public string $assignedTo = '';
public string $equipmentId = '';
public string $location = '';
public string $plannedAt = '';
public string $deadlineAt = '';
public string $resolutionNotes = '';
public ?int $clientRating = null;
public string $clientComment = '';
public array $attachments = [];
public function mount(?ServiceRequest $request = null): void
{
abort_unless(auth()->user()->isAdmin() || auth()->user()->isManager(), 403);
if ($request && $request->exists) {
$this->serviceRequest = $request;
$this->serviceRequestId = $request->id;
$this->title = $request->title;
$this->description = $request->description;
$this->typeId = (string) $request->type_id;
$this->priorityId = (string) $request->priority_id;
$this->statusId = (string) $request->status_id;
$this->clientId = (string) $request->client_id;
$this->assignedTo = (string) $request->assigned_to;
$this->equipmentId = (string) $request->equipment_id;
$this->location = (string) $request->location;
$this->plannedAt = (string) $request->planned_at?->format('Y-m-d\TH:i');
$this->deadlineAt = (string) $request->deadline_at?->format('Y-m-d\TH:i');
$this->resolutionNotes = (string) $request->resolution_notes;
$this->clientRating = $request->client_rating;
$this->clientComment = (string) $request->client_comment;
} else {
$this->statusId = (string) Status::query()->where('slug', StatusSlug::NEW->value)->value('id');
}
}
public function updatedTypeId(): void
{
if (!$this->typeId) {
return;
}
$type = RequestType::query()->find($this->typeId);
if ($type) {
$base = $this->plannedAt !== '' ? Carbon::parse($this->plannedAt) : now();
$this->deadlineAt = $base->addHours($type->sla_hours)->format('Y-m-d\TH:i');
}
}
public function save(RequestService $requestService)
{
$this->validate([
'title' => ['required', 'string', 'max:255'],
'description' => ['required', 'string'],
'typeId' => ['required', 'exists:request_types,id'],
'priorityId' => ['required', 'exists:priorities,id'],
'statusId' => ['nullable', 'exists:statuses,id'],
'clientId' => ['required', 'exists:users,id'],
'assignedTo' => ['nullable', 'exists:users,id'],
'equipmentId' => ['nullable', 'exists:equipment,id'],
'location' => ['nullable', 'string', 'max:255'],
'plannedAt' => ['nullable', 'date'],
'deadlineAt' => ['nullable', 'date'],
'resolutionNotes' => ['nullable', 'string'],
'clientRating' => ['nullable', 'integer', 'between:1,5'],
'clientComment' => ['nullable', 'string', 'max:2000'],
'attachments' => ['nullable', 'array', 'max:5'],
'attachments.*' => ['file', 'max:10240'],
]);
if (!$this->serviceRequestId && empty($this->statusId)) {
throw ValidationException::withMessages([
'statusId' => 'Не удалось определить статус заявки.',
]);
}
$payload = [
'title' => $this->title,
'description' => $this->description,
'type_id' => $this->typeId,
'priority_id' => $this->priorityId,
'status_id' => $this->statusId,
'client_id' => $this->clientId,
'assigned_to' => $this->assignedTo ?: null,
'equipment_id' => $this->equipmentId ?: null,
'location' => $this->location,
'planned_at' => $this->plannedAt ?: null,
'deadline_at' => $this->deadlineAt ?: null,
'resolution_notes' => $this->resolutionNotes ?: null,
'client_rating' => $this->clientRating,
'client_comment' => $this->clientComment ?: null,
'attachments' => $this->attachments,
];
if ($this->serviceRequestId && $this->serviceRequest) {
$request = $requestService->update($this->serviceRequest, $payload, auth()->user());
session()->flash('success', 'Заявка успешно обновлена.');
return redirect()->route('requests.show', $request);
}
$request = $requestService->create($payload, auth()->user());
session()->flash('success', 'Заявка успешно создана.');
return redirect()->route('requests.show', $request);
}
public function render()
{
$clientId = $this->clientId !== '' ? (int) $this->clientId : null;
return view('livewire.requests.request-form-page', [
'types' => RequestType::query()->orderBy('name')->get(),
'priorities' => Priority::query()->orderBy('level')->get(),
'statuses' => Status::query()->orderBy('sort_order')->get(),
'clients' => User::query()->where('role', 'client')->orderBy('name')->get(),
'technicians' => User::query()->where('role', 'technician')->orderBy('name')->get(),
'equipmentItems' => Equipment::query()
->when($clientId, fn ($q) => $q->where('client_id', $clientId))
->orderBy('name')
->get(),
])->title($this->serviceRequestId ? 'Редактирование заявки' : 'Создание заявки');
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
namespace App\Livewire\Requests;
use App\Exports\CsvExport;
use App\Models\Priority;
use App\Models\RequestType;
use App\Models\ServiceRequest;
use App\Models\Status;
use App\Models\User;
use App\Services\ActivityLogService;
use Illuminate\Database\Eloquent\Builder;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.app')]
class RequestIndexPage extends Component
{
use WithPagination;
public string $search = '';
public string $statusId = '';
public string $priorityId = '';
public string $typeId = '';
public string $technicianId = '';
public string $dateFrom = '';
public string $dateTo = '';
public string $sortField = 'created_at';
public string $sortDirection = 'desc';
public int $perPage = 15;
public array $selected = [];
public string $bulkAssignee = '';
public string $bulkStatus = '';
public function mount(): void
{
abort_unless(auth()->user()->isAdmin() || auth()->user()->isManager(), 403);
}
public function updatingSearch(): void
{
$this->resetPage();
}
public function sortBy(string $field): void
{
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
return;
}
$this->sortField = $field;
$this->sortDirection = 'asc';
}
public function clearFilters(): void
{
$this->reset(['search', 'statusId', 'priorityId', 'typeId', 'technicianId', 'dateFrom', 'dateTo']);
$this->resetPage();
}
public function bulkAssign(ActivityLogService $activityLogService): void
{
if (!$this->bulkAssignee || empty($this->selected)) {
return;
}
ServiceRequest::query()
->whereIn('id', $this->selected)
->update(['assigned_to' => $this->bulkAssignee]);
$activityLogService->log(auth()->user(), ServiceRequest::class, 0, 'bulk_assign', 'Массовое назначение заявок', request()->ip());
$this->selected = [];
session()->flash('success', 'Исполнитель назначен выбранным заявкам.');
}
public function bulkChangeStatus(ActivityLogService $activityLogService): void
{
if (!$this->bulkStatus || empty($this->selected)) {
return;
}
ServiceRequest::query()
->whereIn('id', $this->selected)
->update(['status_id' => $this->bulkStatus]);
$activityLogService->log(auth()->user(), ServiceRequest::class, 0, 'bulk_status_change', 'Массовая смена статуса заявок', request()->ip());
$this->selected = [];
session()->flash('success', 'Статус обновлен для выбранных заявок.');
}
public function exportCsv()
{
$rows = $this->query()->get()->map(function (ServiceRequest $request) {
return [
$request->ticket_number,
$request->title,
$request->client?->name,
$request->assignee?->name,
$request->priority?->name,
$request->status?->name,
$request->deadline_at?->format('d.m.Y H:i'),
$request->created_at?->format('d.m.Y H:i'),
];
})->toArray();
return CsvExport::download(
'requests.csv',
['Ticket', 'Заголовок', 'Клиент', 'Исполнитель', 'Приоритет', 'Статус', 'Дедлайн', 'Создана'],
$rows
);
}
private function query(): Builder
{
return ServiceRequest::query()
->with(['client', 'assignee', 'priority', 'status', 'type'])
->when($this->search !== '', function (Builder $query): void {
$query->where(function (Builder $q): void {
$q->where('title', 'like', "%{$this->search}%")
->orWhere('ticket_number', 'like', "%{$this->search}%");
});
})
->when($this->statusId !== '', fn (Builder $q) => $q->where('status_id', $this->statusId))
->when($this->priorityId !== '', fn (Builder $q) => $q->where('priority_id', $this->priorityId))
->when($this->typeId !== '', fn (Builder $q) => $q->where('type_id', $this->typeId))
->when($this->technicianId !== '', fn (Builder $q) => $q->where('assigned_to', $this->technicianId))
->when($this->dateFrom !== '', fn (Builder $q) => $q->whereDate('created_at', '>=', $this->dateFrom))
->when($this->dateTo !== '', fn (Builder $q) => $q->whereDate('created_at', '<=', $this->dateTo))
->orderBy($this->sortField, $this->sortDirection);
}
public function render()
{
return view('livewire.requests.request-index-page', [
'requests' => $this->query()->paginate($this->perPage),
'statuses' => Status::query()->orderBy('sort_order')->get(),
'priorities' => Priority::query()->orderBy('level')->get(),
'types' => RequestType::query()->orderBy('name')->get(),
'technicians' => User::query()->where('role', 'technician')->orderBy('name')->get(),
])->title('Заявки');
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
namespace App\Livewire\Requests;
use App\Models\RequestComment;
use App\Models\ServiceRequest;
use App\Models\Status;
use App\Services\NotificationService;
use App\Services\RequestService;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class RequestShowPage extends Component
{
public ServiceRequest $request;
public string $commentBody = '';
public bool $commentInternal = false;
public string $nextStatusId = '';
public ?int $rating = null;
public string $ratingComment = '';
public function mount(ServiceRequest $request): void
{
Gate::authorize('view', $request);
$this->request = $request->load([
'type',
'priority',
'status',
'client',
'assignee',
'equipment.category',
'comments.user',
'attachments.user',
]);
}
public function addComment(NotificationService $notificationService): void
{
Gate::authorize('update', $this->request);
$data = $this->validate([
'commentBody' => ['required', 'string', 'max:5000'],
'commentInternal' => ['nullable', 'boolean'],
]);
if (($data['commentInternal'] ?? false) && !auth()->user()->isStaff()) {
abort(403);
}
$comment = $this->request->comments()->create([
'user_id' => auth()->id(),
'body' => $data['commentBody'],
'is_internal' => (bool) ($data['commentInternal'] ?? false),
]);
$this->commentBody = '';
$this->commentInternal = false;
$notificationService->notifyCommentAdded($this->request->fresh(['assignee', 'client']), auth()->user());
session()->flash('success', 'Комментарий добавлен.');
$this->request->refresh();
}
public function deleteComment(int $commentId): void
{
$comment = RequestComment::query()->findOrFail($commentId);
abort_unless($comment->user_id === auth()->id(), 403);
$comment->delete();
session()->flash('success', 'Комментарий удален.');
$this->request->refresh();
}
public function changeStatus(RequestService $requestService): void
{
Gate::authorize('update', $this->request);
if ($this->nextStatusId === '') {
return;
}
$target = Status::query()->findOrFail($this->nextStatusId);
$allowed = $requestService->allowedTransitions($this->request->fresh('status'), auth()->user());
if (!in_array($target->slug, $allowed, true)) {
abort(422, 'Переход статуса недоступен.');
}
$requestService->changeStatus($this->request, $target, auth()->user());
$this->nextStatusId = '';
session()->flash('success', 'Статус обновлен.');
$this->request->refresh();
}
public function submitRating(): void
{
Gate::authorize('rate', $this->request);
$data = $this->validate([
'rating' => ['required', 'integer', 'between:1,5'],
'ratingComment' => ['nullable', 'string', 'max:2000'],
]);
$this->request->update([
'client_rating' => $data['rating'],
'client_comment' => $data['ratingComment'] ?? null,
]);
session()->flash('success', 'Спасибо за оценку заявки.');
$this->request->refresh();
}
private function allowedStatuses()
{
$requestService = app(RequestService::class);
$slugs = $requestService->allowedTransitions($this->request->fresh('status'), auth()->user());
return Status::query()->whereIn('slug', $slugs)->orderBy('sort_order')->get();
}
public function render()
{
$isClient = auth()->user()->isClient();
$comments = $this->request->comments()
->with('user')
->when($isClient, fn ($q) => $q->where('is_internal', false))
->latest()
->get();
return view('livewire.requests.request-show-page', [
'allowedStatuses' => $this->allowedStatuses(),
'comments' => $comments,
'activity' => $this->request->id
? \App\Models\ActivityLog::query()
->where('model_type', ServiceRequest::class)
->where('model_id', $this->request->id)
->with('user')
->latest('created_at')
->limit(20)
->get()
: collect(),
])->title("Заявка {$this->request->ticket_number}");
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Livewire\System;
use App\Exports\CsvExport;
use App\Models\ActivityLog;
use App\Models\User;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.app')]
class ActivityLogPage extends Component
{
use WithPagination;
public string $userId = '';
public string $action = '';
public string $dateFrom = '';
public string $dateTo = '';
public function mount(): void
{
abort_unless(auth()->user()->isAdmin(), 403);
}
public function exportCsv()
{
$rows = $this->query()->get()->map(fn (ActivityLog $log) => [
$log->user?->name,
$log->action,
class_basename($log->model_type),
$log->description,
$log->ip_address,
$log->created_at?->format('d.m.Y H:i:s'),
])->toArray();
return CsvExport::download('activity-log.csv', ['Пользователь', 'Действие', 'Сущность', 'Описание', 'IP', 'Дата'], $rows);
}
private function query()
{
return ActivityLog::query()
->with('user')
->when($this->userId !== '', fn ($q) => $q->where('user_id', $this->userId))
->when($this->action !== '', fn ($q) => $q->where('action', 'like', "%{$this->action}%"))
->when($this->dateFrom !== '', fn ($q) => $q->whereDate('created_at', '>=', $this->dateFrom))
->when($this->dateTo !== '', fn ($q) => $q->whereDate('created_at', '<=', $this->dateTo))
->latest('created_at');
}
public function render()
{
return view('livewire.system.activity-log-page', [
'logs' => $this->query()->paginate(20),
'users' => User::query()->orderBy('name')->get(),
])->title('Журнал действий');
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace App\Livewire\System;
use App\Services\SettingsService;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class SettingsPage extends Component
{
public string $companyName = '';
public string $timezone = 'Asia/Yekaterinburg';
public int $defaultSlaHours = 24;
public bool $maintenanceMode = false;
public bool $notifyAssigned = true;
public bool $notifyStatusChanged = true;
public bool $notifyCommentAdded = true;
public bool $notifyOverdue = true;
public bool $notifySlaWarning = true;
/**
* @var array<string, string>
*/
public array $russianTimezones = [
'Europe/Kaliningrad' => 'Калининград (UTC+2)',
'Europe/Moscow' => 'Москва (UTC+3)',
'Europe/Samara' => 'Самара (UTC+4)',
'Asia/Yekaterinburg' => 'Екатеринбург (UTC+5)',
'Asia/Omsk' => 'Омск (UTC+6)',
'Asia/Krasnoyarsk' => 'Красноярск (UTC+7)',
'Asia/Irkutsk' => 'Иркутск (UTC+8)',
'Asia/Yakutsk' => 'Якутск (UTC+9)',
'Asia/Vladivostok' => 'Владивосток (UTC+10)',
'Asia/Magadan' => 'Магадан (UTC+11)',
'Asia/Sakhalin' => 'Сахалин (UTC+11)',
'Asia/Kamchatka' => 'Камчатка (UTC+12)',
];
public function mount(SettingsService $settingsService): void
{
abort_unless(auth()->user()->isAdmin(), 403);
$this->companyName = (string) $settingsService->get('company_name', 'Computer Service');
$this->timezone = (string) $settingsService->get('timezone', 'Asia/Yekaterinburg');
$this->defaultSlaHours = (int) $settingsService->get('default_sla_hours', 24);
$this->maintenanceMode = (bool) $settingsService->get('maintenance_mode', false);
$notifications = $settingsService->emailNotifications();
$this->notifyAssigned = (bool) ($notifications['assigned'] ?? true);
$this->notifyStatusChanged = (bool) ($notifications['status_changed'] ?? true);
$this->notifyCommentAdded = (bool) ($notifications['comment_added'] ?? true);
$this->notifyOverdue = (bool) ($notifications['overdue'] ?? true);
$this->notifySlaWarning = (bool) ($notifications['sla_warning'] ?? true);
}
public function save(SettingsService $settingsService): void
{
$this->validate([
'companyName' => ['required', 'string', 'max:255'],
'timezone' => ['required', Rule::in(array_keys($this->russianTimezones))],
'defaultSlaHours' => ['required', 'integer', 'min:1', 'max:720'],
'maintenanceMode' => ['boolean'],
'notifyAssigned' => ['boolean'],
'notifyStatusChanged' => ['boolean'],
'notifyCommentAdded' => ['boolean'],
'notifyOverdue' => ['boolean'],
'notifySlaWarning' => ['boolean'],
]);
$settingsService->set('company_name', $this->companyName, 'string');
$settingsService->set('timezone', $this->timezone, 'string');
$settingsService->set('default_sla_hours', $this->defaultSlaHours, 'integer');
$settingsService->set('maintenance_mode', $this->maintenanceMode, 'boolean');
$settingsService->set('email_notifications', [
'assigned' => $this->notifyAssigned,
'status_changed' => $this->notifyStatusChanged,
'comment_added' => $this->notifyCommentAdded,
'overdue' => $this->notifyOverdue,
'sla_warning' => $this->notifySlaWarning,
], 'json');
session()->flash('success', 'Настройки сохранены.');
}
public function render()
{
return view('livewire.system.settings-page')->title('Системные настройки');
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace App\Livewire\Tasks;
use App\Models\ServiceRequest;
use App\Models\Status;
use App\Services\RequestService;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class MyTasksPage extends Component
{
public string $priorityId = '';
public string $deadlineFilter = '';
public array $quickComments = [];
public function mount(): void
{
abort_unless(auth()->user()->isTechnician(), 403);
}
public function updateStatus(int $requestId, string $statusId, RequestService $requestService): void
{
$request = ServiceRequest::query()->with('status')->findOrFail($requestId);
abort_unless($request->assigned_to === auth()->id(), 403);
$target = Status::query()->findOrFail($statusId);
$allowed = $requestService->allowedTransitions($request, auth()->user());
if (!in_array($target->slug, $allowed, true)) {
return;
}
$requestService->changeStatus($request, $target, auth()->user());
session()->flash('success', "Статус {$request->ticket_number} обновлен.");
}
public function transitionOptions(ServiceRequest $request)
{
$slugs = app(RequestService::class)->allowedTransitions($request, auth()->user());
return Status::query()->whereIn('slug', $slugs)->orderBy('sort_order')->get();
}
public function addQuickComment(int $requestId): void
{
$body = trim((string) ($this->quickComments[$requestId] ?? ''));
if ($body === '') {
return;
}
$request = ServiceRequest::query()->findOrFail($requestId);
abort_unless($request->assigned_to === auth()->id(), 403);
$request->comments()->create([
'user_id' => auth()->id(),
'body' => $body,
'is_internal' => true,
]);
$this->quickComments[$requestId] = '';
session()->flash('success', "Комментарий к {$request->ticket_number} добавлен.");
}
public function render()
{
$statuses = Status::query()->orderBy('sort_order')->get();
$requests = ServiceRequest::query()
->with(['status', 'priority', 'client', 'equipment'])
->where('assigned_to', auth()->id())
->when($this->priorityId !== '', fn ($q) => $q->where('priority_id', $this->priorityId))
->when($this->deadlineFilter === 'today', fn ($q) => $q->whereDate('deadline_at', now()->toDateString()))
->when($this->deadlineFilter === 'overdue', fn ($q) => $q->overdue())
->orderBy('deadline_at')
->get()
->groupBy(fn (ServiceRequest $request) => $request->status?->slug ?? 'unknown');
return view('livewire.tasks.my-tasks-page', [
'statuses' => $statuses,
'requestsByStatus' => $requests,
'priorities' => \App\Models\Priority::query()->orderBy('level')->get(),
])->title('Мои задачи');
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\Livewire\Users;
use App\Models\Department;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class UserFormPage extends Component
{
public ?User $userModel = null;
public string $name = '';
public string $email = '';
public string $password = '';
public string $password_confirmation = '';
public string $role = 'client';
public string $departmentId = '';
public string $phone = '';
public bool $isActive = true;
public function mount(?User $user = null): void
{
abort_unless(auth()->user()->isAdmin(), 403);
if ($user && $user->exists) {
$this->userModel = $user;
$this->name = $user->name;
$this->email = $user->email;
$this->role = $user->role->value;
$this->departmentId = (string) $user->department_id;
$this->phone = (string) $user->phone;
$this->isActive = (bool) $user->is_active;
}
}
public function save()
{
$emailRule = Rule::unique('users', 'email');
if ($this->userModel) {
$emailRule = $emailRule->ignore($this->userModel->id);
}
$rules = [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', $emailRule],
'role' => ['required', Rule::in(['admin', 'manager', 'technician', 'client'])],
'departmentId' => ['nullable', 'exists:departments,id'],
'phone' => ['nullable', 'string', 'max:32'],
'isActive' => ['boolean'],
];
if ($this->userModel) {
$rules['password'] = ['nullable', 'string', 'min:8', 'same:password_confirmation'];
} else {
$rules['password'] = ['required', 'string', 'min:8', 'same:password_confirmation'];
}
$this->validate($rules);
$payload = [
'name' => $this->name,
'email' => $this->email,
'role' => $this->role,
'department_id' => $this->departmentId ?: null,
'phone' => $this->phone,
'is_active' => $this->isActive,
];
if ($this->password !== '') {
$payload['password'] = Hash::make($this->password);
}
if ($this->userModel) {
$this->userModel->update($payload);
session()->flash('success', 'Пользователь обновлен.');
return redirect()->route('users.show', $this->userModel);
}
$payload['password'] = Hash::make($this->password);
$user = User::query()->create($payload);
session()->flash('success', 'Пользователь создан.');
return redirect()->route('users.show', $user);
}
public function render()
{
return view('livewire.users.user-form-page', [
'departments' => Department::query()->orderBy('name')->get(),
])->title($this->userModel ? 'Редактирование пользователя' : 'Создание пользователя');
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Livewire\Users;
use App\Models\ActivityLog;
use App\Models\ServiceRequest;
use App\Models\User;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class UserShowPage extends Component
{
public User $user;
public function mount(User $user): void
{
abort_unless(auth()->user()->isAdmin() || auth()->user()->isManager(), 403);
$this->user = $user->load('department');
}
public function render()
{
$assigned = ServiceRequest::query()->where('assigned_to', $this->user->id)->count();
$resolved = ServiceRequest::query()
->where('assigned_to', $this->user->id)
->whereHas('status', fn ($q) => $q->whereIn('slug', ['resolved', 'closed']))
->count();
$avgRating = ServiceRequest::query()->where('assigned_to', $this->user->id)->whereNotNull('client_rating')->avg('client_rating');
return view('livewire.users.user-show-page', [
'assigned' => $assigned,
'resolved' => $resolved,
'avgRating' => $avgRating ? round((float) $avgRating, 2) : 0,
'activities' => ActivityLog::query()->where('user_id', $this->user->id)->latest('created_at')->limit(20)->get(),
'recentRequests' => ServiceRequest::query()->where('assigned_to', $this->user->id)->latest()->limit(10)->get(),
])->title("Профиль: {$this->user->name}");
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Livewire\Users;
use App\Models\User;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.app')]
class UsersIndexPage extends Component
{
use WithPagination;
public string $search = '';
public string $role = '';
public string $status = '';
public function mount(): void
{
abort_unless(auth()->user()->isAdmin() || auth()->user()->isManager(), 403);
}
public function render()
{
$users = User::query()
->with('department')
->when($this->search !== '', function ($query): void {
$query->where(function ($q): void {
$q->where('name', 'like', "%{$this->search}%")
->orWhere('email', 'like', "%{$this->search}%");
});
})
->when($this->role !== '', fn ($q) => $q->where('role', $this->role))
->when($this->status !== '', fn ($q) => $q->where('is_active', $this->status === 'active'))
->latest()
->paginate(15);
return view('livewire.users.users-index-page', [
'users' => $users,
])->title('Пользователи');
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ActivityLog extends Model
{
use HasFactory;
public const UPDATED_AT = null;
protected $fillable = [
'user_id',
'model_type',
'model_id',
'action',
'description',
'ip_address',
'created_at',
];
protected $casts = [
'created_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Department extends Model
{
use HasFactory;
protected $fillable = [
'name',
'description',
];
public function users(): HasMany
{
return $this->hasMany(User::class);
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Models;
use App\Enums\EquipmentCondition;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Equipment extends Model
{
use HasFactory;
protected $table = 'equipment';
protected $fillable = [
'name',
'serial_number',
'inventory_number',
'category_id',
'client_id',
'purchase_date',
'warranty_until',
'condition',
'notes',
];
protected $casts = [
'purchase_date' => 'date',
'warranty_until' => 'date',
'condition' => EquipmentCondition::class,
];
public function category(): BelongsTo
{
return $this->belongsTo(EquipmentCategory::class, 'category_id');
}
public function client(): BelongsTo
{
return $this->belongsTo(User::class, 'client_id');
}
public function requests(): HasMany
{
return $this->hasMany(ServiceRequest::class, 'equipment_id');
}
public function scopeWarrantyExpired(Builder $query): Builder
{
return $query->whereNotNull('warranty_until')->whereDate('warranty_until', '<', now());
}
public function scopeWarrantyExpiringSoon(Builder $query, int $days = 30): Builder
{
return $query
->whereNotNull('warranty_until')
->whereDate('warranty_until', '>=', now())
->whereDate('warranty_until', '<=', now()->addDays($days));
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class EquipmentCategory extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'description',
];
public function equipment(): HasMany
{
return $this->hasMany(Equipment::class, 'category_id');
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Priority extends Model
{
use HasFactory;
protected $fillable = [
'name',
'color_hex',
'level',
];
protected $casts = [
'level' => 'integer',
];
public function requests(): HasMany
{
return $this->hasMany(ServiceRequest::class, 'priority_id');
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class RequestAttachment extends Model
{
use HasFactory;
protected $fillable = [
'request_id',
'user_id',
'filename',
'path',
'size',
];
protected $casts = [
'size' => 'integer',
];
public function request(): BelongsTo
{
return $this->belongsTo(ServiceRequest::class, 'request_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class RequestComment extends Model
{
use HasFactory;
protected $fillable = [
'request_id',
'user_id',
'body',
'is_internal',
];
protected $casts = [
'is_internal' => 'boolean',
];
public function request(): BelongsTo
{
return $this->belongsTo(ServiceRequest::class, 'request_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class RequestType extends Model
{
use HasFactory;
protected $fillable = [
'name',
'description',
'sla_hours',
];
protected $casts = [
'sla_hours' => 'integer',
];
public function requests(): HasMany
{
return $this->hasMany(ServiceRequest::class, 'type_id');
}
}
+140
View File
@@ -0,0 +1,140 @@
<?php
namespace App\Models;
use App\Enums\StatusSlug;
use App\Enums\UserRole;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class ServiceRequest extends Model
{
use HasFactory;
use SoftDeletes;
protected $table = 'requests';
protected $fillable = [
'ticket_number',
'title',
'description',
'type_id',
'priority_id',
'status_id',
'client_id',
'assigned_to',
'equipment_id',
'location',
'planned_at',
'deadline_at',
'started_at',
'completed_at',
'resolution_notes',
'client_rating',
'client_comment',
];
protected $casts = [
'planned_at' => 'datetime',
'deadline_at' => 'datetime',
'started_at' => 'datetime',
'completed_at' => 'datetime',
'client_rating' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'deleted_at' => 'datetime',
];
public function type(): BelongsTo
{
return $this->belongsTo(RequestType::class, 'type_id');
}
public function priority(): BelongsTo
{
return $this->belongsTo(Priority::class, 'priority_id');
}
public function status(): BelongsTo
{
return $this->belongsTo(Status::class, 'status_id');
}
public function client(): BelongsTo
{
return $this->belongsTo(User::class, 'client_id');
}
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_to');
}
public function equipment(): BelongsTo
{
return $this->belongsTo(Equipment::class, 'equipment_id');
}
public function comments(): HasMany
{
return $this->hasMany(RequestComment::class, 'request_id')->latest();
}
public function attachments(): HasMany
{
return $this->hasMany(RequestAttachment::class, 'request_id')->latest();
}
public function scopeOpen(Builder $query): Builder
{
return $query->whereHas('status', fn (Builder $status) => $status->where('is_final', false));
}
public function scopeOverdue(Builder $query): Builder
{
return $query
->whereNotNull('deadline_at')
->where('deadline_at', '<', now())
->whereHas('status', fn (Builder $status) => $status->where('is_final', false));
}
public function scopeMine(Builder $query, User $user): Builder
{
if ($user->isClient()) {
return $query->where('client_id', $user->id);
}
if ($user->isTechnician()) {
return $query->where('assigned_to', $user->id);
}
return $query;
}
public function scopeForRole(Builder $query, User $user): Builder
{
return match ($user->role) {
UserRole::CLIENT => $query->where('client_id', $user->id),
UserRole::TECHNICIAN => $query->where('assigned_to', $user->id),
default => $query,
};
}
public function isOverdue(): bool
{
if ($this->deadline_at === null) {
return false;
}
return $this->deadline_at->isPast() && !$this->status?->is_final;
}
public function hasStatus(StatusSlug $slug): bool
{
return $this->status?->slug === $slug->value;
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
use HasFactory;
protected $fillable = [
'key',
'value',
'type',
];
public static function getValue(string $key, mixed $default = null): mixed
{
$setting = static::query()->where('key', $key)->first();
if (!$setting) {
return $default;
}
return $setting->castValue();
}
public static function setValue(string $key, mixed $value, string $type = 'string'): self
{
return static::query()->updateOrCreate(
['key' => $key],
['value' => static::serializeValue($value, $type), 'type' => $type]
);
}
public function castValue(): mixed
{
return match ($this->type) {
'integer' => (int) $this->value,
'boolean' => filter_var($this->value, FILTER_VALIDATE_BOOLEAN),
'json' => $this->value ? json_decode($this->value, true, 512, JSON_THROW_ON_ERROR) : [],
default => $this->value,
};
}
private static function serializeValue(mixed $value, string $type): ?string
{
if ($value === null) {
return null;
}
return match ($type) {
'json' => json_encode($value, JSON_THROW_ON_ERROR),
'boolean' => $value ? '1' : '0',
default => (string) $value,
};
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Status extends Model
{
use HasFactory;
protected $fillable = [
'name',
'color_hex',
'slug',
'is_final',
'sort_order',
];
protected $casts = [
'is_final' => 'boolean',
'sort_order' => 'integer',
];
public function requests(): HasMany
{
return $this->hasMany(ServiceRequest::class, 'status_id');
}
public function scopeFinal(Builder $query): Builder
{
return $query->where('is_final', true);
}
public function scopeActiveFlow(Builder $query): Builder
{
return $query->where('is_final', false);
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace App\Models;
use App\Enums\UserRole;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens;
use HasFactory;
use Notifiable;
use SoftDeletes;
protected $fillable = [
'name',
'email',
'password',
'role',
'phone',
'avatar',
'department_id',
'is_active',
'last_login_at',
];
protected $hidden = [
'password',
'remember_token',
];
protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'last_login_at' => 'datetime',
'is_active' => 'boolean',
'role' => UserRole::class,
];
public function department(): BelongsTo
{
return $this->belongsTo(Department::class);
}
public function clientRequests(): HasMany
{
return $this->hasMany(ServiceRequest::class, 'client_id');
}
public function assignedRequests(): HasMany
{
return $this->hasMany(ServiceRequest::class, 'assigned_to');
}
public function requestComments(): HasMany
{
return $this->hasMany(RequestComment::class);
}
public function requestAttachments(): HasMany
{
return $this->hasMany(RequestAttachment::class);
}
public function activityLogs(): HasMany
{
return $this->hasMany(ActivityLog::class);
}
public function equipment(): HasMany
{
return $this->hasMany(Equipment::class, 'client_id');
}
public function isAdmin(): bool
{
return $this->role === UserRole::ADMIN;
}
public function isManager(): bool
{
return $this->role === UserRole::MANAGER;
}
public function isTechnician(): bool
{
return $this->role === UserRole::TECHNICIAN;
}
public function isClient(): bool
{
return $this->role === UserRole::CLIENT;
}
public function isStaff(): bool
{
return $this->isAdmin() || $this->isManager() || $this->isTechnician();
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Notifications;
use App\Models\ServiceRequest;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class RequestAssignedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(private readonly ServiceRequest $request)
{
}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Новая назначенная заявка')
->line("Вам назначена заявка {$this->request->ticket_number}.")
->action('Открыть заявку', route('requests.show', $this->request));
}
public function toArray(object $notifiable): array
{
return [
'title' => 'Назначена новая заявка',
'message' => "Вам назначена заявка {$this->request->ticket_number}",
'request_id' => $this->request->id,
'url' => route('requests.show', $this->request),
];
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Notifications;
use App\Models\ServiceRequest;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class RequestCommentAddedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(private readonly ServiceRequest $request, private readonly User $author)
{
}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Новый комментарий к заявке')
->line("{$this->author->name} добавил комментарий к заявке {$this->request->ticket_number}.")
->action('Открыть заявку', route('requests.show', $this->request));
}
public function toArray(object $notifiable): array
{
return [
'title' => 'Новый комментарий',
'message' => "{$this->author->name} добавил комментарий к {$this->request->ticket_number}",
'request_id' => $this->request->id,
'url' => route('requests.show', $this->request),
];
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Notifications;
use App\Models\ServiceRequest;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class RequestOverdueNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(private readonly ServiceRequest $request)
{
}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Просроченная заявка')
->line("Заявка {$this->request->ticket_number} просрочена.")
->action('Открыть заявку', route('requests.show', $this->request));
}
public function toArray(object $notifiable): array
{
return [
'title' => 'Просроченная заявка',
'message' => "Заявка {$this->request->ticket_number} просрочена",
'request_id' => $this->request->id,
'url' => route('requests.show', $this->request),
];
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Notifications;
use App\Models\ServiceRequest;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class RequestStatusChangedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(private readonly ServiceRequest $request)
{
}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Статус заявки изменен')
->line("Статус заявки {$this->request->ticket_number} изменился на «{$this->request->status?->name}»." )
->action('Открыть заявку', route('my-requests.show', $this->request));
}
public function toArray(object $notifiable): array
{
return [
'title' => 'Изменение статуса заявки',
'message' => "Заявка {$this->request->ticket_number}: {$this->request->status?->name}",
'request_id' => $this->request->id,
'url' => route('my-requests.show', $this->request),
];
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Notifications;
use App\Models\ServiceRequest;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class SlaWarningNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(private readonly ServiceRequest $request)
{
}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('SLA предупреждение')
->line("До дедлайна заявки {$this->request->ticket_number} осталось менее часа.")
->action('Открыть заявку', route('requests.show', $this->request));
}
public function toArray(object $notifiable): array
{
return [
'title' => 'Предупреждение SLA',
'message' => "До дедлайна заявки {$this->request->ticket_number} осталось менее часа",
'request_id' => $this->request->id,
'url' => route('requests.show', $this->request),
];
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Policies;
use App\Models\ActivityLog;
use App\Models\User;
class ActivityLogPolicy
{
public function viewAny(User $user): bool
{
return $user->isAdmin();
}
public function view(User $user, ActivityLog $activityLog): bool
{
return $user->isAdmin();
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Policies;
use App\Models\Department;
use App\Models\User;
class DepartmentPolicy
{
public function before(User $user, string $ability): bool|null
{
return $user->isAdmin() ? true : null;
}
public function viewAny(User $user): bool
{
return false;
}
public function view(User $user, Department $department): bool
{
return false;
}
public function create(User $user): bool
{
return false;
}
public function update(User $user, Department $department): bool
{
return false;
}
public function delete(User $user, Department $department): bool
{
return false;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Policies;
use App\Models\EquipmentCategory;
use App\Models\User;
class EquipmentCategoryPolicy
{
public function before(User $user, string $ability): bool|null
{
return $user->isAdmin() ? true : null;
}
public function viewAny(User $user): bool
{
return false;
}
public function view(User $user, EquipmentCategory $equipmentCategory): bool
{
return false;
}
public function create(User $user): bool
{
return false;
}
public function update(User $user, EquipmentCategory $equipmentCategory): bool
{
return false;
}
public function delete(User $user, EquipmentCategory $equipmentCategory): bool
{
return false;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Policies;
use App\Models\Equipment;
use App\Models\User;
class EquipmentPolicy
{
public function before(User $user, string $ability): bool|null
{
return $user->isAdmin() ? true : null;
}
public function viewAny(User $user): bool
{
return false;
}
public function view(User $user, Equipment $equipment): bool
{
return false;
}
public function create(User $user): bool
{
return false;
}
public function update(User $user, Equipment $equipment): bool
{
return false;
}
public function delete(User $user, Equipment $equipment): bool
{
return false;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Policies;
use App\Models\Priority;
use App\Models\User;
class PriorityPolicy
{
public function before(User $user, string $ability): bool|null
{
return $user->isAdmin() ? true : null;
}
public function viewAny(User $user): bool
{
return false;
}
public function view(User $user, Priority $priority): bool
{
return false;
}
public function create(User $user): bool
{
return false;
}
public function update(User $user, Priority $priority): bool
{
return false;
}
public function delete(User $user, Priority $priority): bool
{
return false;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Policies;
use App\Models\RequestType;
use App\Models\User;
class RequestTypePolicy
{
public function before(User $user, string $ability): bool|null
{
return $user->isAdmin() ? true : null;
}
public function viewAny(User $user): bool
{
return false;
}
public function view(User $user, RequestType $requestType): bool
{
return false;
}
public function create(User $user): bool
{
return false;
}
public function update(User $user, RequestType $requestType): bool
{
return false;
}
public function delete(User $user, RequestType $requestType): bool
{
return false;
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\Policies;
use App\Enums\StatusSlug;
use App\Models\ServiceRequest;
use App\Models\User;
class ServiceRequestPolicy
{
public function viewAny(User $user): bool
{
return true;
}
public function view(User $user, ServiceRequest $serviceRequest): bool
{
if ($user->isAdmin() || $user->isManager()) {
return true;
}
if ($user->isTechnician()) {
return $serviceRequest->assigned_to === $user->id;
}
return $serviceRequest->client_id === $user->id;
}
public function create(User $user): bool
{
return $user->isAdmin() || $user->isManager() || $user->isClient();
}
public function update(User $user, ServiceRequest $serviceRequest): bool
{
if ($user->isAdmin() || $user->isManager()) {
return true;
}
if ($user->isTechnician()) {
return $serviceRequest->assigned_to === $user->id;
}
return $serviceRequest->client_id === $user->id
&& $serviceRequest->status?->slug === StatusSlug::NEW->value;
}
public function delete(User $user, ServiceRequest $serviceRequest): bool
{
return $user->isAdmin();
}
public function addInternalComment(User $user, ServiceRequest $serviceRequest): bool
{
if (!$user->isStaff()) {
return false;
}
return $this->view($user, $serviceRequest);
}
public function rate(User $user, ServiceRequest $serviceRequest): bool
{
if (!$user->isClient()) {
return false;
}
return $serviceRequest->client_id === $user->id
&& in_array($serviceRequest->status?->slug, [StatusSlug::RESOLVED->value, StatusSlug::CLOSED->value], true);
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Policies;
use App\Models\Setting;
use App\Models\User;
class SettingPolicy
{
public function viewAny(User $user): bool
{
return $user->isAdmin();
}
public function update(User $user, Setting $setting): bool
{
return $user->isAdmin();
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Policies;
use App\Models\Status;
use App\Models\User;
class StatusPolicy
{
public function before(User $user, string $ability): bool|null
{
return $user->isAdmin() ? true : null;
}
public function viewAny(User $user): bool
{
return false;
}
public function view(User $user, Status $status): bool
{
return false;
}
public function create(User $user): bool
{
return false;
}
public function update(User $user, Status $status): bool
{
return false;
}
public function delete(User $user, Status $status): bool
{
return false;
}
}

Some files were not shown because too many files have changed in this diff Show More