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
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[docker-compose.yml]
indent_size = 4
+59
View File
@@ -0,0 +1,59 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=
BROADCAST_DRIVER=log
CACHE_DRIVER=file
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
SESSION_DRIVER=file
SESSION_LIFETIME=120
MEMCACHED_HOST=127.0.0.1
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_HOST=
PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1
VITE_APP_NAME="${APP_NAME}"
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
VITE_PUSHER_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
+11
View File
@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
+19
View File
@@ -0,0 +1,19 @@
/.phpunit.cache
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/vendor
.env
.env.backup
.env.production
.phpunit.result.cache
Homestead.json
Homestead.yaml
auth.json
npm-debug.log
yarn-error.log
/.fleet
/.idea
/.vscode
+66
View File
@@ -0,0 +1,66 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
You may also try the [Laravel Bootcamp](https://bootcamp.laravel.com), where you will be guided through building a modern Laravel application from scratch.
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
## Laravel Sponsors
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
### Premium Partners
- **[Vehikl](https://vehikl.com/)**
- **[Tighten Co.](https://tighten.co)**
- **[WebReinvent](https://webreinvent.com/)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel/)**
- **[Cyber-Duck](https://cyber-duck.co.uk)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Jump24](https://jump24.co.uk)**
- **[Redberry](https://redberry.international/laravel/)**
- **[Active Logic](https://activelogic.com)**
- **[byte5](https://byte5.de)**
- **[OP.GG](https://op.gg)**
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
@@ -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;
}
}

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