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
+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}");
}
}