diff --git a/app/Actions/OvertimeRequest/WaitForTechApprovalAction.php b/app/Actions/OvertimeRequest/WaitForTechApprovalAction.php index ce0e1f60..17855257 100644 --- a/app/Actions/OvertimeRequest/WaitForTechApprovalAction.php +++ b/app/Actions/OvertimeRequest/WaitForTechApprovalAction.php @@ -4,9 +4,10 @@ namespace Toby\Actions\OvertimeRequest; -use Toby\Actions\VacationRequest\ApproveAction; +use Spatie\Permission\Models\Permission; use Toby\Domain\OvertimeRequestStateManager; use Toby\Models\OvertimeRequest; +use Toby\Notifications\OvertimeRequestsWaitsForApprovalNotification; class WaitForTechApprovalAction { @@ -18,5 +19,19 @@ public function __construct( public function execute(OvertimeRequest $overtimeRequest): void { $this->stateManager->waitForTechnical($overtimeRequest); + + $this->notifyAuthorizedUsers($overtimeRequest); + } + + protected function notifyAuthorizedUsers(OvertimeRequest $overtimeRequest): void + { + $users = Permission::findByName("manageOvertimeAsTechnicalApprover") + ->users() + ->with("permissions") + ->get(); + + foreach ($users as $user) { + $user->notify(new OvertimeRequestsWaitsForApprovalNotification($overtimeRequest, $user)); + } } } diff --git a/app/Console/Commands/SendOvertimeRequestSummariesToApprovers.php b/app/Console/Commands/SendOvertimeRequestSummariesToApprovers.php new file mode 100644 index 00000000..78221fa1 --- /dev/null +++ b/app/Console/Commands/SendOvertimeRequestSummariesToApprovers.php @@ -0,0 +1,69 @@ +option("force") && !$this->shouldHandle($now)) { + return; + } + + $users = Permission::findByName("receiveOvertimeRequestsSummaryNotification")->users()->get(); + + foreach ($users as $user) { + $overtimeRequests = OvertimeRequest::query() + ->states(OvertimeRequestStatesRetriever::waitingForUserActionStates($user)) + ->orderByDesc("updated_at") + ->get(); + + if ($overtimeRequests->isNotEmpty() && $this->worksToday($user, $now)) { + $user->notify(new OvertimeRequestsSummaryNotification(Carbon::today(), $overtimeRequests)); + } + } + } + + protected function shouldHandle(CarbonInterface $day): bool + { + $holidays = Holiday::query()->whereDate("date", $day)->pluck("date"); + + if ($day->isWeekend()) { + return false; + } + + if ($holidays->contains($day)) { + return false; + } + + return true; + } + + protected function worksToday(User $user, Carbon $date): bool + { + $count = $user->overtimeRequests() + ->whereDate("from", "<=", $date) + ->whereDate("to", ">=", $date) + ->states(OvertimeRequestStatesRetriever::successStates()) + ->count(); + + return $count === 0; + } +} diff --git a/app/Console/Commands/SendVacationRequestSummariesToApprovers.php b/app/Console/Commands/SendVacationRequestSummariesToApprovers.php index 34d1b6ec..badc898c 100644 --- a/app/Console/Commands/SendVacationRequestSummariesToApprovers.php +++ b/app/Console/Commands/SendVacationRequestSummariesToApprovers.php @@ -34,6 +34,7 @@ public function handle(VacationTypeConfigRetriever $configRetriever): void foreach ($users as $user) { $vacationRequests = VacationRequest::query() ->states(VacationRequestStatesRetriever::waitingForUserActionStates($user)) + ->orderByDesc("updated_at") ->get(); if ($vacationRequests->isNotEmpty() && $this->worksToday($user, $now, $configRetriever)) { diff --git a/app/Notifications/OvertimeRequestNotification.php b/app/Notifications/OvertimeRequestNotification.php new file mode 100644 index 00000000..a68b8788 --- /dev/null +++ b/app/Notifications/OvertimeRequestNotification.php @@ -0,0 +1,38 @@ + $this->overtimeRequest->id]); + $seeDetails = __("See details"); + + return (new SlackMessage()) + ->text("{$this->buildDescription()}\n <{$url}|{$seeDetails}>"); + } + + abstract protected function buildDescription(): string; +} diff --git a/app/Notifications/OvertimeRequestsSummaryNotification.php b/app/Notifications/OvertimeRequestsSummaryNotification.php new file mode 100644 index 00000000..2ebfe399 --- /dev/null +++ b/app/Notifications/OvertimeRequestsSummaryNotification.php @@ -0,0 +1,42 @@ +loadRelations(); + + return (new SlackMessage()) + ->text(__("Requests wait for your approval - status for day :date:", ["date" => $this->day->toDisplayString()])) + ->withAttachment(new OvertimeRequestsAttachment($this->overtimeRequests)); + } + + protected function loadRelations(): void + { + $this->overtimeRequests->load(["user"]); + } +} diff --git a/app/Notifications/OvertimeRequestsWaitsForApprovalNotification.php b/app/Notifications/OvertimeRequestsWaitsForApprovalNotification.php new file mode 100644 index 00000000..3b22a8a7 --- /dev/null +++ b/app/Notifications/OvertimeRequestsWaitsForApprovalNotification.php @@ -0,0 +1,55 @@ + $this->overtimeRequest->id]); + $seeDetails = __("See details"); + + $actions = [ + AttachmentAction::create("overtime_technical_approval", __("Approve"), "button") + ->setValue("overtime_technical_approval") + ->setStyle("primary"), + AttachmentAction::create("overtime_reject", __("Reject"), "button") + ->setValue("overtime_reject") + ->setStyle("danger"), + ]; + + $attachment = Attachment::create() + ->setCallbackId("overtime:{$this->overtimeRequest->id}") + ->setText(__("Available actions:")) + ->setActions($actions); + + return (new SlackMessage()) + ->text("{$this->buildDescription()}\n <{$url}|{$seeDetails}>") + ->withAttachment($attachment); + } + + protected function buildDescription(): string + { + $title = $this->overtimeRequest->name; + $requester = $this->overtimeRequest->user->profile->full_name; + $from = $this->overtimeRequest->from; + $to = $this->overtimeRequest->to; + $hours = $this->overtimeRequest->hours; + + $date = "{$from->format(DateFormats::DATETIME_DISPLAY)} - {$to->format(DateFormats::DATETIME_DISPLAY)}"; + + return __("The request :title is waiting for your technical approval.\nUser: :requester\nDate: :date (number of hours: :hours)", [ + "title" => $title, + "requester" => $requester, + "date" => $date, + "hours" => $hours, + ]); + } +} diff --git a/app/Notifications/VacationRequestWaitsForApprovalNotification.php b/app/Notifications/VacationRequestWaitsForApprovalNotification.php index 30d66827..6f4dea86 100644 --- a/app/Notifications/VacationRequestWaitsForApprovalNotification.php +++ b/app/Notifications/VacationRequestWaitsForApprovalNotification.php @@ -38,7 +38,7 @@ public function toSlack(): SlackMessage } $attachment = Attachment::create() - ->setCallbackId((string)$this->vacationRequest->id) + ->setCallbackId("vacation:{$this->vacationRequest->id}") ->setText(__("Available actions:")) ->setActions($actions); diff --git a/app/Slack/Elements/OvertimeRequestsAttachment.php b/app/Slack/Elements/OvertimeRequestsAttachment.php new file mode 100644 index 00000000..5712f6f2 --- /dev/null +++ b/app/Slack/Elements/OvertimeRequestsAttachment.php @@ -0,0 +1,37 @@ +setColor("#527aba") + ->setItems($this->mapOvertimeRequests($overtimeRequests)); + } + + protected function mapOvertimeRequests(Collection $overtimeRequests): Collection + { + return $overtimeRequests->map(function (OvertimeRequest $request): string { + $url = route("overtime.requests.show", ["overtimeRequest" => $request->id]); + + $date = "{$request->from->format(DateFormats::DATETIME_DISPLAY)} - {$request->to->format(DateFormats::DATETIME_DISPLAY)}"; + + return __("<:url|:request> - :user (:date)", [ + "url" => $url, + "request" => $request->name, + "user" => $request->user->profile->full_name, + "date" => $date, + ]); + }); + } +} diff --git a/app/Slack/SlackActionController.php b/app/Slack/SlackActionController.php index e25a1653..d89f2a93 100644 --- a/app/Slack/SlackActionController.php +++ b/app/Slack/SlackActionController.php @@ -8,12 +8,17 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request as IlluminateRequest; use Spatie\SlashCommand\Controller as SlackController; +use Toby\Actions\OvertimeRequest\AcceptAsTechnicalAction as OvertimeAcceptAsTechnicalAction; +use Toby\Actions\OvertimeRequest\RejectAction as OvertimeRejectAction; use Toby\Actions\VacationRequest\AcceptAsAdministrativeAction; use Toby\Actions\VacationRequest\AcceptAsTechnicalAction; use Toby\Actions\VacationRequest\RejectAction; +use Toby\Helpers\DateFormats; +use Toby\Models\OvertimeRequest; use Toby\Models\User; use Toby\Models\VacationRequest; use Toby\Slack\Traits\FindsUserBySlackId; +use Toby\States\OvertimeRequest\WaitingForTechnical as OvertimeWaitingForTechnical; use Toby\States\VacationRequest\WaitingForAdministrative; use Toby\States\VacationRequest\WaitingForTechnical; @@ -22,7 +27,7 @@ class SlackActionController extends SlackController use FindsUserBySlackId; use AuthorizesRequests; - public function handleVacationRequestAction(IlluminateRequest $request, AcceptAsTechnicalAction $acceptAsTechnical, AcceptAsAdministrativeAction $acceptAsAdministrative, RejectAction $reject): JsonResponse + public function handleAction(IlluminateRequest $request, AcceptAsTechnicalAction $acceptAsTechnical, AcceptAsAdministrativeAction $acceptAsAdministrative, RejectAction $reject, OvertimeAcceptAsTechnicalAction $overtimeAcceptAsTechnical, OvertimeRejectAction $overtimeRejectAction): JsonResponse { $this->verifyWithSigning($request); @@ -32,11 +37,21 @@ public function handleVacationRequestAction(IlluminateRequest $request, AcceptAs $user = $this->findUserBySlackId($userSlackId); - $vacationRequestId = $payload["callback_id"]; + [$type, $id] = explode(":", $payload["callback_id"]); $action = $payload["actions"][0]["value"]; - $vacationRequest = VacationRequest::query()->findOrFail($vacationRequestId); + if ($type === "overtime") { + $overtimeRequest = OvertimeRequest::query()->findOrFail($id); + + return match ($action) { + "overtime_technical_approval" => $this->handleOvertimeTechnicalApproval($user, $overtimeRequest, $overtimeAcceptAsTechnical), + "overtime_reject" => $this->handleOvertimeRejection($user, $overtimeRequest, $overtimeRejectAction), + default => $this->prepareUnrecognizedActionError(), + }; + } + + $vacationRequest = VacationRequest::query()->findOrFail($id); return match ($action) { "technical_approval" => $this->handleTechnicalApproval($user, $vacationRequest, $acceptAsTechnical), @@ -46,6 +61,17 @@ public function handleVacationRequestAction(IlluminateRequest $request, AcceptAs }; } + public function prepareOvertimeActionError(OvertimeRequest $overtimeRequest): JsonResponse + { + return response()->json([ + "text" => __("You cannot perform this action because the current status of the request :title by user :requester is :status.", [ + "title" => $overtimeRequest->name, + "requester" => $overtimeRequest->user->profile->full_name, + "status" => $overtimeRequest->state->label(), + ]), + ]); + } + protected function prepareAuthorizationError(): JsonResponse { return response()->json([ @@ -152,4 +178,54 @@ protected function handleRejection(User $user, VacationRequest $vacationRequest, ]), ]); } + + protected function handleOvertimeTechnicalApproval(User $user, OvertimeRequest $overtimeRequest, OvertimeAcceptAsTechnicalAction $acceptAsTechnical): JsonResponse + { + if ($user->cannot("acceptAsTechApprover", $overtimeRequest)) { + return $this->prepareAuthorizationError(); + } + + if (!$overtimeRequest->state->equals(OvertimeWaitingForTechnical::class)) { + return $this->prepareOvertimeActionError($overtimeRequest); + } + + $acceptAsTechnical->execute($overtimeRequest, $user); + + $title = $overtimeRequest->name; + $requester = $overtimeRequest->user->profile->full_name; + $from = $overtimeRequest->from; + $to = $overtimeRequest->to; + $hours = $overtimeRequest->hours; + + $date = "{$from->format(DateFormats::DATETIME_DISPLAY)} - {$to->format(DateFormats::DATETIME_DISPLAY)}"; + + return response()->json([ + "text" => __("The request :title has been approved by you as a technical approver.\nUser: :requester\nDate: :date (number of hours: :hours)", [ + "title" => $overtimeRequest->name, + "requester" => $overtimeRequest->user->profile->full_name, + "date" => $date, + "hours" => $hours, + ]), + ]); + } + + protected function handleOvertimeRejection(User $user, OvertimeRequest $overtimeRequest, OvertimeRejectAction $reject): JsonResponse + { + if ($user->cannot("reject", $overtimeRequest)) { + return $this->prepareAuthorizationError(); + } + + if (!$overtimeRequest->state->equals(OvertimeWaitingForTechnical::class)) { + return $this->prepareOvertimeActionError($overtimeRequest); + } + + $reject->execute($overtimeRequest, $user); + + return response()->json([ + "text" => __("The request :title from user :requester has been rejected by you.", [ + "title" => $overtimeRequest->name, + "requester" => $overtimeRequest->user->profile->full_name, + ]), + ]); + } } diff --git a/config/permission.php b/config/permission.php index 480bfa1d..3320ec6b 100644 --- a/config/permission.php +++ b/config/permission.php @@ -62,6 +62,7 @@ "receiveVacationRequestStatusChangedNotification", "receiveBenefitsReportCreationNotification", "manageEquipment", + "receiveOvertimeRequestsSummaryNotification", ], "permission_roles" => [ Role::Administrator->value => [ @@ -86,6 +87,7 @@ "receiveVacationRequestWaitsForApprovalNotification", "receiveVacationRequestStatusChangedNotification", "manageEquipment", + "receiveOvertimeRequestsSummaryNotification", ], Role::AdministrativeApprover->value => [ "managePermissions", @@ -121,6 +123,7 @@ "receiveVacationRequestsSummaryNotification", "receiveVacationRequestWaitsForApprovalNotification", "receiveVacationRequestStatusChangedNotification", + "receiveOvertimeRequestsSummaryNotification", ], Role::Employee->value => [], ], diff --git a/lang/pl.json b/lang/pl.json index a0dec3d1..5e28ac3d 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -193,5 +193,8 @@ "Assigned at": "Przypisany od", "User history created.": "Historia użytkownika utworzona.", "User history updated.": "Historia użytkownika zaktualizowana.", - "User history deleted.": "Historia użytkownika usunięta." + "User history deleted.": "Historia użytkownika usunięta.", + "settled": "rozliczony", + "The request :title is waiting for your technical approval.\nUser: :requester\nDate: :date (number of hours: :hours)": "Wniosek :title czeka na Twoją akceptację techniczną.\nPracownik: :requester\nData: :date (liczba godzin: :hours)", + "The request :title has been approved by you as a technical approver.\nUser: :requester\nDate: :date (number of hours: :hours)": "Wniosek :title został zaakceptowany przez Ciebie jako przełożonego technicznego.\nPracownik: :requester\nData: :date (liczba godzin: :hours)" } diff --git a/resources/js/Composables/permissionInfo.js b/resources/js/Composables/permissionInfo.js index 802e4166..64fe9274 100644 --- a/resources/js/Composables/permissionInfo.js +++ b/resources/js/Composables/permissionInfo.js @@ -124,6 +124,11 @@ const permissionsInfo = [ 'value': 'receiveVacationRequestStatusChangedNotification', 'section': 'Powiadomienia', }, + { + 'name': 'Podsumowania wniosków o nadgodziny', + 'value': 'receiveOvertimeRequestsSummaryNotification', + 'section': 'Powiadomienia', + }, ] export function usePermissionInfo() { diff --git a/resources/js/Pages/OvertimeRequest/Show.vue b/resources/js/Pages/OvertimeRequest/Show.vue index 526d36a8..64837cc1 100644 --- a/resources/js/Pages/OvertimeRequest/Show.vue +++ b/resources/js/Pages/OvertimeRequest/Show.vue @@ -190,7 +190,7 @@ defineProps({ preserve-scroll class="inline-flex justify-center items-center py-2 px-4 font-medium text-blue-700 bg-blue-100 hover:bg-blue-200 rounded-md border border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 sm:text-sm" > - Rozlicz nadogdziny + Rozlicz nadgodziny diff --git a/routes/api.php b/routes/api.php index 10ff404a..305d3969 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,7 +13,7 @@ use Toby\Slack\SlackCommandController; Route::post("slack/commands", [SlackCommandController::class, "getResponse"]); -Route::post("slack/actions", [SlackActionController::class, "handleVacationRequestAction"]); +Route::post("slack/actions", [SlackActionController::class, "handleAction"]); Route::middleware("auth:sanctum")->group(function (): void { Route::post("vacation/calculate-days", CalculateVacationDaysController::class); diff --git a/routes/console.php b/routes/console.php index 4f51e2ef..c659305b 100644 --- a/routes/console.php +++ b/routes/console.php @@ -9,6 +9,7 @@ use Toby\Console\Commands\SendNotificationAboutBenefitsReportCreation; use Toby\Console\Commands\SendNotificationAboutUpcomingAndOverdueMedicalExams; use Toby\Console\Commands\SendNotificationAboutUpcomingAndOverdueOhsTraining; +use Toby\Console\Commands\SendOvertimeRequestSummariesToApprovers; use Toby\Console\Commands\SendVacationRequestSummariesToApprovers; use Toby\Jobs\CheckYearPeriod; @@ -23,6 +24,11 @@ ->dailyAt("08:30") ->onOneServer(); +Schedule::command(SendOvertimeRequestSummariesToApprovers::class) + ->weekdays() + ->dailyAt("08:30") + ->onOneServer(); + Schedule::command(SendNotificationAboutUpcomingAndOverdueMedicalExams::class) ->monthlyOn(1, "08:00") ->onOneServer(); diff --git a/tests/Feature/PermissionTest.php b/tests/Feature/PermissionTest.php index a91ed803..d16409c9 100644 --- a/tests/Feature/PermissionTest.php +++ b/tests/Feature/PermissionTest.php @@ -50,7 +50,8 @@ public function testAdminCanSeeEditEmployeePermissionsForm(): void ->where("receiveVacationRequestsSummaryNotification", false) ->where("receiveVacationRequestWaitsForApprovalNotification", false) ->where("receiveVacationRequestStatusChangedNotification", false) - ->where("manageEquipment", false), + ->where("manageEquipment", false) + ->where("receiveOvertimeRequestsSummaryNotification", false), ), ); } @@ -92,7 +93,8 @@ public function testAdminCanSeeEditTechnicalApproverPermissionsForm(): void ->where("receiveVacationRequestsSummaryNotification", true) ->where("receiveVacationRequestWaitsForApprovalNotification", true) ->where("receiveVacationRequestStatusChangedNotification", true) - ->where("manageEquipment", false), + ->where("manageEquipment", false) + ->where("receiveOvertimeRequestsSummaryNotification", true), ), ); } @@ -134,7 +136,8 @@ public function testAdminCanSeeEditAdministrativeApproverPermissionsForm(): void ->where("receiveVacationRequestsSummaryNotification", true) ->where("receiveVacationRequestWaitsForApprovalNotification", true) ->where("receiveVacationRequestStatusChangedNotification", true) - ->where("manageEquipment", true), + ->where("manageEquipment", true) + ->where("receiveOvertimeRequestsSummaryNotification", false), ), ); } diff --git a/tests/Unit/SendOvertimeRequestSummariesTest.php b/tests/Unit/SendOvertimeRequestSummariesTest.php new file mode 100644 index 00000000..2cd2f358 --- /dev/null +++ b/tests/Unit/SendOvertimeRequestSummariesTest.php @@ -0,0 +1,134 @@ +createCurrentYearPeriod(); + $this->travelTo(now()->startOfWeek()); + } + + public function testSummariesAreSentOnlyToProperApprovers(): void + { + $currentYearPeriod = YearPeriod::current(); + + $user = User::factory()->employee()->create(); + $technicalApprover = User::factory()->technicalApprover()->create(); + $administrativeApprover = User::factory()->administrativeApprover()->create(); + $admin = User::factory()->admin()->create(); + + OvertimeRequest::factory() + ->for($user) + ->for($currentYearPeriod) + ->create(["state" => WaitingForTechnical::class]); + + $this->artisan(SendOvertimeRequestSummariesToApprovers::class) + ->execute(); + + Notification::assertSentTo([$technicalApprover, $admin], OvertimeRequestsSummaryNotification::class); + Notification::assertNotSentTo([$user, $administrativeApprover], OvertimeRequestsSummaryNotification::class); + } + + public function testSummariesAreNotSentOnWeekends(): void + { + $this->travelTo(now()->endOfWeek()); + $currentYearPeriod = YearPeriod::current(); + + $user = User::factory()->employee()->create(); + + OvertimeRequest::factory() + ->for($user) + ->for($currentYearPeriod) + ->create(["state" => WaitingForTechnical::class]); + + $this->artisan(SendOvertimeRequestSummariesToApprovers::class) + ->execute(); + + Notification::assertNothingSent(); + } + + public function testSummariesAreSentOnlyIfOvertimeRequestWaitingForActionExists(): void + { + $currentYearPeriod = YearPeriod::current(); + + $user = User::factory()->employee()->create(); + $technicalApprover = User::factory()->technicalApprover()->create(); + $admin = User::factory()->admin()->create(); + + OvertimeRequest::factory() + ->for($user) + ->for($currentYearPeriod) + ->create(["state" => WaitingForTechnical::class]); + + $this->artisan(SendOvertimeRequestSummariesToApprovers::class) + ->execute(); + + Notification::assertSentTo([$technicalApprover, $admin], OvertimeRequestsSummaryNotification::class); + Notification::assertNotSentTo([$user], OvertimeRequestsSummaryNotification::class); + } + + public function testSummariesAreNotSentIfThereAreNoWaitingForActionOvertimeRequests(): void + { + $currentYearPeriod = YearPeriod::current(); + + $user = User::factory()->employee()->create(); + $technicalApprover = User::factory()->technicalApprover()->create(); + $admin = User::factory()->admin()->create(); + + OvertimeRequest::factory() + ->for($user) + ->for($currentYearPeriod) + ->create(["state" => Approved::class]); + + OvertimeRequest::factory() + ->for($user) + ->for($currentYearPeriod) + ->create(["state" => Cancelled::class]); + + OvertimeRequest::factory() + ->for($user) + ->for($currentYearPeriod) + ->create(["state" => Rejected::class]); + + OvertimeRequest::factory() + ->for($user) + ->for($currentYearPeriod) + ->create(["state" => Created::class]); + + OvertimeRequest::factory() + ->for($user) + ->for($currentYearPeriod) + ->create(["state" => Settled::class]); + + $this->artisan(SendOvertimeRequestSummariesToApprovers::class) + ->execute(); + + Notification::assertNotSentTo([$user, $technicalApprover, $admin], OvertimeRequestsSummaryNotification::class); + } +}