diff --git a/app/Actions/OvertimeRequest/AcceptAsTechnicalAction.php b/app/Actions/OvertimeRequest/AcceptAsTechnicalAction.php new file mode 100644 index 00000000..4a7800e7 --- /dev/null +++ b/app/Actions/OvertimeRequest/AcceptAsTechnicalAction.php @@ -0,0 +1,24 @@ +stateManager->acceptAsTechnical($overtimeRequest, $user); + + $this->approveAction->execute($overtimeRequest); + } +} diff --git a/app/Actions/OvertimeRequest/ApproveAction.php b/app/Actions/OvertimeRequest/ApproveAction.php new file mode 100644 index 00000000..b42ee417 --- /dev/null +++ b/app/Actions/OvertimeRequest/ApproveAction.php @@ -0,0 +1,21 @@ +stateManager->approve($overtimeRequest, $user); + } +} diff --git a/app/Actions/OvertimeRequest/CancelAction.php b/app/Actions/OvertimeRequest/CancelAction.php new file mode 100644 index 00000000..66391ca2 --- /dev/null +++ b/app/Actions/OvertimeRequest/CancelAction.php @@ -0,0 +1,21 @@ +stateManager->cancel($overtimeRequest, $user); + } +} diff --git a/app/Actions/OvertimeRequest/CreateAction.php b/app/Actions/OvertimeRequest/CreateAction.php new file mode 100644 index 00000000..e15ce218 --- /dev/null +++ b/app/Actions/OvertimeRequest/CreateAction.php @@ -0,0 +1,57 @@ +createVacationRequest($data, $creator); + + $this->handleCreatedOvertimeRequest($overtimeRequest); + + return $overtimeRequest; + } + + /** + * @throws ValidationException + */ + protected function createVacationRequest(array $data, User $creator): OvertimeRequest + { + /** @var OvertimeRequest $overtimeRequest */ + $overtimeRequest = $creator->createdOvertimeRequests()->make($data); + $overtimeRequest->hours = $this->overtimeCalculator->calculateHours($overtimeRequest->from, $overtimeRequest->to); + + $this->overtimeRequestValidator->validate($overtimeRequest); + + $overtimeRequest->save(); + + $this->stateManager->markAsCreated($overtimeRequest); + + return $overtimeRequest; + } + + protected function handleCreatedOvertimeRequest(OvertimeRequest $overtimeRequest): void + { + $this->waitForTechApprovalAction->execute($overtimeRequest); + } +} diff --git a/app/Actions/OvertimeRequest/RejectAction.php b/app/Actions/OvertimeRequest/RejectAction.php new file mode 100644 index 00000000..8a74c2fb --- /dev/null +++ b/app/Actions/OvertimeRequest/RejectAction.php @@ -0,0 +1,21 @@ +stateManager->reject($overtimeRequest, $user); + } +} diff --git a/app/Actions/OvertimeRequest/SettleAction.php b/app/Actions/OvertimeRequest/SettleAction.php new file mode 100644 index 00000000..71c09b29 --- /dev/null +++ b/app/Actions/OvertimeRequest/SettleAction.php @@ -0,0 +1,21 @@ +stateManager->settle($overtimeRequest, $user); + } +} diff --git a/app/Actions/OvertimeRequest/WaitForTechApprovalAction.php b/app/Actions/OvertimeRequest/WaitForTechApprovalAction.php new file mode 100644 index 00000000..ce0e1f60 --- /dev/null +++ b/app/Actions/OvertimeRequest/WaitForTechApprovalAction.php @@ -0,0 +1,22 @@ +stateManager->waitForTechnical($overtimeRequest); + } +} diff --git a/app/Domain/DashboardAggregator.php b/app/Domain/DashboardAggregator.php index 30b75814..adbd63a8 100644 --- a/app/Domain/DashboardAggregator.php +++ b/app/Domain/DashboardAggregator.php @@ -51,7 +51,7 @@ public function aggregateCalendarData(User $user, YearPeriod $yearPeriod): array ->vacations() ->with(["vacationRequest.vacations", "vacationRequest.user.profile"]) ->whereBelongsTo($yearPeriod) - ->cache() + ->cache("vacations{$user->id}") ->approved() ->get() ->mapWithKeys( @@ -64,7 +64,7 @@ public function aggregateCalendarData(User $user, YearPeriod $yearPeriod): array ->vacations() ->with(["vacationRequest.vacations", "vacationRequest.user.profile"]) ->whereBelongsTo($yearPeriod) - ->cache() + ->cache("vacations{$user->id}") ->pending() ->get() ->mapWithKeys( @@ -92,7 +92,6 @@ public function aggregateVacationRequests(User $user, YearPeriod $yearPeriod): J ->states(VacationRequestStatesRetriever::waitingForUserActionStates($user)) ->latest("updated_at") ->limit(3) - ->cache() ->get(); } else { $vacationRequests = $user->vacationRequests() @@ -100,7 +99,6 @@ public function aggregateVacationRequests(User $user, YearPeriod $yearPeriod): J ->whereBelongsTo($yearPeriod) ->latest("updated_at") ->limit(3) - ->cache() ->get(); } diff --git a/app/Domain/OvertimeCalculator.php b/app/Domain/OvertimeCalculator.php new file mode 100644 index 00000000..fb731b11 --- /dev/null +++ b/app/Domain/OvertimeCalculator.php @@ -0,0 +1,18 @@ +diffInMinutes($to); + + return (int)ceil($hours / 60); + } +} diff --git a/app/Domain/OvertimeRequestStateManager.php b/app/Domain/OvertimeRequestStateManager.php new file mode 100644 index 00000000..f76e2d5e --- /dev/null +++ b/app/Domain/OvertimeRequestStateManager.php @@ -0,0 +1,80 @@ +createActivity($overtimeRequest, null, $overtimeRequest->state, $overtimeRequest->creator); + } + + public function approve(OvertimeRequest $overtimeRequest, ?User $user = null): void + { + $this->changeState($overtimeRequest, Approved::class, $user); + } + + public function reject(OvertimeRequest $overtimeRequest, User $user): void + { + $this->changeState($overtimeRequest, Rejected::class, $user); + } + + public function cancel(OvertimeRequest $overtimeRequest, User $user): void + { + $this->changeState($overtimeRequest, Cancelled::class, $user); + } + + public function acceptAsTechnical(OvertimeRequest $overtimeRequest, User $user): void + { + $this->changeState($overtimeRequest, AcceptedByTechnical::class, $user); + } + + public function waitForTechnical(OvertimeRequest $overtimeRequest): void + { + $this->changeState($overtimeRequest, WaitingForTechnical::class); + } + + public function settle(OvertimeRequest $overtimeRequest, User $user): void + { + $this->changeState($overtimeRequest, Settled::class, $user); + } + + protected function changeState(OvertimeRequest $overtimeRequest, string $state, ?User $user = null): void + { + $previousState = $overtimeRequest->state; + $overtimeRequest->state->transitionTo($state); + $overtimeRequest->save(); + + $this->createActivity($overtimeRequest, $previousState, $overtimeRequest->state, $user); + } + + protected function createActivity( + OvertimeRequest $overtimeRequest, + ?OvertimeRequestState $from, + OvertimeRequestState $to, + ?User $user = null, + ): void { + $overtimeRequest->activities()->create([ + "from" => $from, + "to" => $to, + "user_id" => $user?->id, + ]); + } +} diff --git a/app/Domain/OvertimeRequestStatesRetriever.php b/app/Domain/OvertimeRequestStatesRetriever.php new file mode 100644 index 00000000..abf4a072 --- /dev/null +++ b/app/Domain/OvertimeRequestStatesRetriever.php @@ -0,0 +1,75 @@ +role) { + Role::TechnicalApprover, Role::Administrator => [WaitingForTechnical::class], + default => [], + }; + } + + public static function all(): array + { + return [ + ...self::pendingStates(), + ...self::successStates(), + ...self::failedStates(), + ...self::settledStates(), + ]; + } + + public static function filterByStatusGroup(string $filter, ?User $user = null): array + { + return match ($filter) { + "pending" => self::pendingStates(), + "success" => self::successStates(), + "failed" => self::failedStates(), + "waiting_for_action" => self::waitingForUserActionStates($user), + "settled" => self::settledStates(), + default => self::all(), + }; + } +} diff --git a/app/Domain/UserVacationStatsRetriever.php b/app/Domain/UserVacationStatsRetriever.php index 57bf66c4..5d936ba7 100644 --- a/app/Domain/UserVacationStatsRetriever.php +++ b/app/Domain/UserVacationStatsRetriever.php @@ -103,7 +103,7 @@ public function getVacationDaysLimit(User $user, YearPeriod $yearPeriod): int { return $user->vacationLimits() ->whereBelongsTo($yearPeriod) - ->cache() + ->cache("vacations{$user->id}") ->first()?->limit ?? 0; } @@ -111,7 +111,7 @@ public function hasVacationDaysLimit(User $user, YearPeriod $yearPeriod): bool { return $user->vacationLimits() ->whereBelongsTo($yearPeriod) - ->cache() + ->cache("vacations{$user->id}") ->first()?->hasVacation() ?? false; } diff --git a/app/Enums/SettlementType.php b/app/Enums/SettlementType.php new file mode 100644 index 00000000..e84b871f --- /dev/null +++ b/app/Enums/SettlementType.php @@ -0,0 +1,27 @@ +value); + } + + public static function casesToSelect(): array + { + return array_map( + fn(SettlementType $enum): array => [ + "label" => $enum->label(), + "value" => $enum->value, + ], + SettlementType::cases(), + ); + } +} diff --git a/app/Helpers/DateFormats.php b/app/Helpers/DateFormats.php new file mode 100644 index 00000000..ef8bc2c5 --- /dev/null +++ b/app/Helpers/DateFormats.php @@ -0,0 +1,12 @@ +calculateHours($request->from(), $request->to()); + + return new JsonResponse($hours); + } +} diff --git a/app/Http/Controllers/OvertimeRequestController.php b/app/Http/Controllers/OvertimeRequestController.php new file mode 100644 index 00000000..b6d2e3bf --- /dev/null +++ b/app/Http/Controllers/OvertimeRequestController.php @@ -0,0 +1,224 @@ +user()->can("listAllOvertimeRequests")) { + return redirect()->route("overtime.requests.indexForApprovers"); + } + $user = $request->user(); + $this->authorize("canUseOvertimeRequestFunctionality", $user); + + $status = $request->get("status", "all"); + + $overtimeRequests = $user + ->overtimeRequests() + ->with(["user.permissions", "user.profile"]) + ->whereBelongsTo($yearPeriodRetriever->selected()) + ->latest() + ->states(OvertimeRequestStatesRetriever::filterByStatusGroup($status, $request->user())) + ->paginate(); + + $pending = $user + ->overtimeRequests() + ->whereBelongsTo($yearPeriodRetriever->selected()) + ->states(OvertimeRequestStatesRetriever::pendingStates()) + ->cache(key: "overtime{$user->id}") + ->count(); + + $success = $user + ->overtimeRequests() + ->whereBelongsTo($yearPeriodRetriever->selected()) + ->states(OvertimeRequestStatesRetriever::successStates()) + ->cache(key: "overtime{$user->id}") + ->count(); + + $failed = $user + ->overtimeRequests() + ->whereBelongsTo($yearPeriodRetriever->selected()) + ->states(OvertimeRequestStatesRetriever::failedStates()) + ->cache(key: "overtime{$user->id}") + ->count(); + + $settled = $user + ->overtimeRequests() + ->whereBelongsTo($yearPeriodRetriever->selected()) + ->states(OvertimeRequestStatesRetriever::settledStates()) + ->cache(key: "overtime{$user->id}") + ->count(); + + return inertia("OvertimeRequest/Index", [ + "requests" => OvertimeRequestResource::collection($overtimeRequests), + "stats" => [ + "all" => $pending + $success + $failed + $settled, + "pending" => $pending, + "success" => $success, + "failed" => $failed, + "settled" => $settled, + ], + "filters" => [ + "status" => $status, + ], + ]); + } + + public function indexForApprovers( + Request $request, + YearPeriodRetriever $yearPeriodRetriever, + ): RedirectResponse|Response { + if ($request->user()->cannot("listAllOvertimeRequests")) { + abort(403); + } + + $yearPeriod = $yearPeriodRetriever->selected(); + $status = $request->get("status", "all"); + $user = $request->get("user"); + + $overtimeRequests = OvertimeRequest::query() + ->with(["user.permissions", "user.profile"]) + ->whereBelongsTo($yearPeriod) + ->when($user !== null, fn(Builder $query): Builder => $query->where("user_id", $user)) + ->states(OvertimeRequestStatesRetriever::filterByStatusGroup($status, $request->user())) + ->latest() + ->paginate(); + + $users = User::query() + ->orderByProfileField("last_name") + ->orderByProfileField("first_name") + ->get(); + + return inertia("OvertimeRequest/IndexForApprovers", [ + "requests" => OvertimeRequestResource::collection($overtimeRequests), + "users" => SimpleUserResource::collection($users), + "filters" => [ + "status" => $status, + "user" => (int)$user, + ], + ]); + } + + /** + * @throws AuthorizationException + */ + public function show(OvertimeRequest $overtimeRequest): Response + { + $this->authorize("show", $overtimeRequest); + + $overtimeRequest->load(["user.permissions", "user.profile", "activities.user.profile"]); + + return inertia("OvertimeRequest/Show", [ + "request" => new OvertimeRequestResource($overtimeRequest), + "activities" => OvertimeRequestActivityResource::collection($overtimeRequest->activities), + ]); + } + + public function create(Request $request): Response + { + $this->authorize("canUseOvertimeRequestFunctionality", $request->user()); + + return inertia("OvertimeRequest/Create", [ + "settlementTypes" => SettlementType::casesToSelect(), + "overtimeFromDate" => $request->get("from_date"), + ]); + } + + public function store(OvertimeRequestRequest $request, CreateAction $createAction): RedirectResponse + { + $this->authorize("canUseOvertimeRequestFunctionality", $request->user()); + + $overtimeRequest = $createAction->execute($request->data(), $request->user()); + + return redirect() + ->route("overtime.requests.show", $overtimeRequest) + ->with("success", __("Request created.")); + } + + /** + * @throws AuthorizationException + */ + public function reject( + Request $request, + OvertimeRequest $overtimeRequest, + RejectAction $rejectAction, + ): RedirectResponse { + $this->authorize("reject", $overtimeRequest); + + $rejectAction->execute($overtimeRequest, $request->user()); + + return redirect()->back() + ->with("success", __("Request rejected.")); + } + + /** + * @throws AuthorizationException + */ + public function cancel( + Request $request, + OvertimeRequest $overtimeRequest, + CancelAction $cancelAction, + ): RedirectResponse { + $this->authorize("cancel", $overtimeRequest); + + $cancelAction->execute($overtimeRequest, $request->user()); + + return redirect()->back() + ->with("success", __("Request cancelled.")); + } + + /** + * @throws AuthorizationException + */ + public function acceptAsTechnical( + Request $request, + OvertimeRequest $overtimeRequest, + AcceptAsTechnicalAction $acceptAsTechnicalAction, + ): RedirectResponse { + $this->authorize("acceptAsTechApprover", $overtimeRequest); + + $acceptAsTechnicalAction->execute($overtimeRequest, $request->user()); + + return redirect()->back() + ->with("success", __("Request accepted.")); + } + + /** + * @throws AuthorizationException + */ + public function settle( + Request $request, + OvertimeRequest $overtimeRequest, + SettleAction $settleAction, + ): RedirectResponse { + $this->authorize("settle", $overtimeRequest); + + $settleAction->execute($overtimeRequest, $request->user()); + + return redirect()->back() + ->with("success", __("Overtime settled.")); + } +} diff --git a/app/Http/Controllers/VacationRequestController.php b/app/Http/Controllers/VacationRequestController.php index efbe4c9f..11762f84 100644 --- a/app/Http/Controllers/VacationRequestController.php +++ b/app/Http/Controllers/VacationRequestController.php @@ -37,41 +37,46 @@ class VacationRequestController extends Controller { public function index(Request $request, YearPeriodRetriever $yearPeriodRetriever): Response|RedirectResponse { - if ($request->user()->can("listAllRequests")) { + $user = $request->user(); + + if ($user->can("listAllRequests")) { return redirect()->route("vacation.requests.indexForApprovers"); } $status = $request->get("status", "all"); $withoutRemote = $request->boolean("withoutRemote", default: false); - $vacationRequests = $request->user() + $vacationRequests = $user ->vacationRequests() ->with(["vacations.user.profile", "user.permissions", "user.profile"]) ->whereBelongsTo($yearPeriodRetriever->selected()) ->latest() - ->states(VacationRequestStatesRetriever::filterByStatusGroup($status, $request->user())) + ->states(VacationRequestStatesRetriever::filterByStatusGroup($status, $user)) ->when($withoutRemote, fn(Builder $query): Builder => $query->excludeType(VacationType::RemoteWork)) ->paginate(); - $pending = $request->user() + $pending = $user ->vacationRequests() ->whereBelongsTo($yearPeriodRetriever->selected()) ->states(VacationRequestStatesRetriever::pendingStates()) ->when($withoutRemote, fn(Builder $query): Builder => $query->excludeType(VacationType::RemoteWork)) + ->cache(key: "vacations{$user->id}") ->count(); - $success = $request->user() + $success = $user ->vacationRequests() ->whereBelongsTo($yearPeriodRetriever->selected()) ->states(VacationRequestStatesRetriever::successStates()) ->when($withoutRemote, fn(Builder $query): Builder => $query->excludeType(VacationType::RemoteWork)) + ->cache(key: "vacations{$user->id}") ->count(); - $failed = $request->user() + $failed = $user ->vacationRequests() ->whereBelongsTo($yearPeriodRetriever->selected()) ->states(VacationRequestStatesRetriever::failedStates()) ->when($withoutRemote, fn(Builder $query): Builder => $query->excludeType(VacationType::RemoteWork)) + ->cache(key: "vacations{$user->id}") ->count(); return inertia("VacationRequest/Index", [ diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index be7e6018..23544729 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -10,9 +10,11 @@ use Illuminate\Http\Request; use Inertia\Middleware; use Spatie\Permission\Models\Permission; +use Toby\Domain\OvertimeRequestStatesRetriever; use Toby\Domain\VacationRequestStatesRetriever; use Toby\Helpers\YearPeriodRetriever; use Toby\Http\Resources\UserResource; +use Toby\Models\OvertimeRequest; use Toby\Models\User; use Toby\Models\VacationRequest; @@ -30,6 +32,7 @@ public function share(Request $request): array "flash" => $this->getFlashData($request), "years" => $this->getYearsData($request), "vacationRequestsCount" => $this->getVacationRequestsCount($request), + "overtimeRequestsCount" => $this->getOvertimeRequestsCount($request), "deployInformation" => $this->getDeployInformation(), "lastUpdate" => $this->cache->rememberForever("last_update", fn(): string => Carbon::now()->toIso8601String()), ]); @@ -48,6 +51,7 @@ protected function getAuthData(Request $request): Closure $permission->name => $user && $user->hasPermissionTo($permission), ], ), + "overtimeEnabled" => $user && $user->can("canUseOvertimeRequestFunctionality", $user), ]; } @@ -79,6 +83,20 @@ protected function getVacationRequestsCount(Request $request): Closure : null; } + protected function getOvertimeRequestsCount(Request $request): Closure + { + $user = $request->user(); + + return fn(): ?int => $user && $user->can("listAllRequests") + ? OvertimeRequest::query() + ->whereBelongsTo($this->yearPeriodRetriever->selected()) + ->states( + OvertimeRequestStatesRetriever::waitingForUserActionStates($user), + ) + ->count() + : null; + } + protected function getDeployInformation(): Closure { return fn(): array => [ diff --git a/app/Http/Requests/Api/CalculateOvertimeHoursRequest.php b/app/Http/Requests/Api/CalculateOvertimeHoursRequest.php new file mode 100644 index 00000000..0529e5da --- /dev/null +++ b/app/Http/Requests/Api/CalculateOvertimeHoursRequest.php @@ -0,0 +1,37 @@ + ["required", "date_format:" . DateFormats::DATETIME, new YearPeriodExists()], + "to" => ["required", "date_format:" . DateFormats::DATETIME, new YearPeriodExists()], + ]; + } + + public function from(): Carbon + { + return Carbon::create($this->request->get("from")); + } + + public function to(): Carbon + { + return Carbon::create($this->request->get("to")); + } + + public function yearPeriod(): YearPeriod + { + return YearPeriod::findByYear(Carbon::create($this->request->get("from"))->year); + } +} diff --git a/app/Http/Requests/Api/CalculateVacationDaysRequest.php b/app/Http/Requests/Api/CalculateVacationDaysRequest.php index e77dfed5..c8f2240f 100644 --- a/app/Http/Requests/Api/CalculateVacationDaysRequest.php +++ b/app/Http/Requests/Api/CalculateVacationDaysRequest.php @@ -8,6 +8,7 @@ use Illuminate\Support\Carbon; use Illuminate\Validation\Rules\Enum; use Toby\Enums\VacationType; +use Toby\Helpers\DateFormats; use Toby\Http\Rules\YearPeriodExists; use Toby\Models\YearPeriod; @@ -17,8 +18,8 @@ public function rules(): array { return [ "vacationType" => ["required", new Enum(VacationType::class)], - "from" => ["required", "date_format:Y-m-d", new YearPeriodExists()], - "to" => ["required", "date_format:Y-m-d", new YearPeriodExists()], + "from" => ["required", "date_format:" . DateFormats::DATE, new YearPeriodExists()], + "to" => ["required", "date_format:" . DateFormats::DATE, new YearPeriodExists()], ]; } diff --git a/app/Http/Requests/EquipmentRequest.php b/app/Http/Requests/EquipmentRequest.php index 3eaed672..5ba60e39 100644 --- a/app/Http/Requests/EquipmentRequest.php +++ b/app/Http/Requests/EquipmentRequest.php @@ -6,6 +6,7 @@ use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Toby\Helpers\DateFormats; class EquipmentRequest extends FormRequest { @@ -16,7 +17,7 @@ public function rules(): array "name" => ["required", "min:3", "max:80"], "isMobile" => ["required", "boolean"], "assignee" => ["required_with:assignedAt", "nullable", "exists:users,id"], - "assignedAt" => ["required_with:assignee", "nullable", "date_format:Y-m-d"], + "assignedAt" => ["required_with:assignee", "nullable", "date_format:" . DateFormats::DATE], "labels" => ["array", "distinct"], ]; } diff --git a/app/Http/Requests/HolidayRequest.php b/app/Http/Requests/HolidayRequest.php index 96cc56a5..1069f267 100644 --- a/app/Http/Requests/HolidayRequest.php +++ b/app/Http/Requests/HolidayRequest.php @@ -7,6 +7,7 @@ use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Carbon; use Illuminate\Validation\Rule; +use Toby\Helpers\DateFormats; use Toby\Http\Rules\YearPeriodExists; use Toby\Models\YearPeriod; @@ -17,7 +18,7 @@ public function rules(): array return [ "name" => ["required", "min:3", "max:150"], "date" => ["required", - "date_format:Y-m-d", + "date_format:" . DateFormats::DATE, Rule::unique("holidays", "date")->ignore($this->holiday), new YearPeriodExists(), ], diff --git a/app/Http/Requests/OvertimeRequestRequest.php b/app/Http/Requests/OvertimeRequestRequest.php new file mode 100644 index 00000000..1470fb63 --- /dev/null +++ b/app/Http/Requests/OvertimeRequestRequest.php @@ -0,0 +1,44 @@ + ["required", "exists:users,id"], + "from" => ["required", "date_format:" . DateFormats::DATETIME, new YearPeriodExists()], + "to" => ["required", "date_format:" . DateFormats::DATETIME, "after:from", new YearPeriodExists()], + "type" => ["required", new Enum(SettlementType::class)], + "comment" => ["nullable"], + ]; + } + + public function data(): array + { + return [ + "user_id" => $this->get("user"), + "from" => $this->get("from"), + "to" => $this->get("to"), + "settlement_type" => $this->get("type"), + "year_period_id" => $this->yearPeriod()->id, + "comment" => $this->get("comment"), + ]; + } + + public function yearPeriod(): YearPeriod + { + return YearPeriod::findByYear(Carbon::create($this->get("from"))->year); + } +} diff --git a/app/Http/Requests/UserRequest.php b/app/Http/Requests/UserRequest.php index f547cd29..81e05b3a 100644 --- a/app/Http/Requests/UserRequest.php +++ b/app/Http/Requests/UserRequest.php @@ -9,6 +9,7 @@ use Illuminate\Validation\Rules\Enum; use Toby\Enums\EmploymentForm; use Toby\Enums\Role; +use Toby\Helpers\DateFormats; class UserRequest extends FormRequest { @@ -21,8 +22,8 @@ public function rules(): array "role" => ["required", new Enum(Role::class)], "position" => ["required"], "employmentForm" => ["required", new Enum(EmploymentForm::class)], - "employmentDate" => ["required", "date_format:Y-m-d"], - "birthday" => ["required", "date_format:Y-m-d", "before:today"], + "employmentDate" => ["required", "date_format:" . DateFormats::DATE], + "birthday" => ["required", "date_format:" . DateFormats::DATE, "before:today"], "slackId" => [], ]; } diff --git a/app/Http/Requests/VacationRequestRequest.php b/app/Http/Requests/VacationRequestRequest.php index 94e07412..a089eaa5 100644 --- a/app/Http/Requests/VacationRequestRequest.php +++ b/app/Http/Requests/VacationRequestRequest.php @@ -8,6 +8,7 @@ use Illuminate\Support\Carbon; use Illuminate\Validation\Rules\Enum; use Toby\Enums\VacationType; +use Toby\Helpers\DateFormats; use Toby\Http\Rules\YearPeriodExists; use Toby\Models\YearPeriod; @@ -18,8 +19,8 @@ public function rules(): array return [ "user" => ["required", "exists:users,id"], "type" => ["required", new Enum(VacationType::class)], - "from" => ["required", "date_format:Y-m-d", new YearPeriodExists()], - "to" => ["required", "date_format:Y-m-d", new YearPeriodExists()], + "from" => ["required", "date_format:" . DateFormats::DATE, new YearPeriodExists()], + "to" => ["required", "date_format:" . DateFormats::DATE, new YearPeriodExists()], "flowSkipped" => ["nullable", "boolean"], "comment" => ["nullable"], ]; diff --git a/app/Http/Resources/OvertimeRequestActivityResource.php b/app/Http/Resources/OvertimeRequestActivityResource.php new file mode 100644 index 00000000..f21831fd --- /dev/null +++ b/app/Http/Resources/OvertimeRequestActivityResource.php @@ -0,0 +1,22 @@ + $this->created_at->toDisplayString(), + "time" => $this->created_at->format("H:i"), + "user" => $this->user ? $this->user->profile->full_name : __("System"), + "state" => $this->to, + ]; + } +} diff --git a/app/Http/Resources/OvertimeRequestResource.php b/app/Http/Resources/OvertimeRequestResource.php new file mode 100644 index 00000000..b6c88e90 --- /dev/null +++ b/app/Http/Resources/OvertimeRequestResource.php @@ -0,0 +1,53 @@ +user(); + + return [ + "id" => $this->id, + "name" => $this->name, + "user" => new SimpleUserResource($this->user), + "state" => $this->state, + "from" => $this->from->format(DateFormats::DATETIME_DISPLAY), + "to" => $this->to->format(DateFormats::DATETIME_DISPLAY), + "hours" => $this->hours, + "settlementType" => $this->settlement_type, + "settled" => $this->settled, + "displayDate" => $this->getDate($this->from->toDisplayString(DateFormats::DATETIME_DISPLAY), $this->to->toDisplayString(DateFormats::DATETIME_DISPLAY)), + "comment" => $this->comment, + "can" => [ + "acceptAsTechnical" => $this->resource->state->canTransitionTo(AcceptedByTechnical::class) + && $user->can("acceptAsTechApprover", $this->resource), + "reject" => $this->resource->state->canTransitionTo(Rejected::class) + && $user->can("reject", $this->resource), + "cancel" => $this->resource->state->canTransitionTo(Cancelled::class) + && $user->can("cancel", $this->resource), + "settle" => $this->resource->state->canTransitionTo(Settled::class) + && $user->can("settle", $this->resource), + ], + ]; + } + + private function getDate(string $from, string $to): string + { + return ($from !== $to) + ? "{$from} - {$to}" + : $from; + } +} diff --git a/app/Models/OvertimeRequest.php b/app/Models/OvertimeRequest.php new file mode 100644 index 00000000..7fc3ebdb --- /dev/null +++ b/app/Models/OvertimeRequest.php @@ -0,0 +1,104 @@ + OvertimeRequestState::class, + "from" => "datetime", + "to" => "datetime", + "settled" => "boolean", + "settlement_type" => SettlementType::class, + ]; + protected $perPage = 50; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class) + ->withTrashed(); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, "creator_id"); + } + + public function yearPeriod(): BelongsTo + { + return $this->belongsTo(YearPeriod::class); + } + + public function activities(): HasMany + { + return $this->hasMany(OvertimeRequestActivity::class); + } + + public function scopeStates(Builder $query, OvertimeRequestState|array $states): Builder + { + return $query->whereState("state", $states); + } + + public function scopeOverlapsWith(Builder $query, self $overtimeRequest): Builder + { + return $query->where(function (Builder $query) use ($overtimeRequest): void { + // right side of period + $query->where("from", "<=", $overtimeRequest->from) + ->where("to", ">", $overtimeRequest->from) + ->where("to", "<", $overtimeRequest->to); + })->orWhere(function (Builder $query) use ($overtimeRequest): void { + // left side of period + $query->where("from", "<", $overtimeRequest->to) + ->where("to", ">", $overtimeRequest->to) + ->where("from", ">", $overtimeRequest->from); + })->orWhere(function (Builder $query) use ($overtimeRequest): void { + // inside period + $query->where("from", "<=", $overtimeRequest->from) + ->where("to", ">=", $overtimeRequest->to); + })->orWhere(function (Builder $query) use ($overtimeRequest): void { + // includes period + $query->where("from", ">=", $overtimeRequest->from) + ->where("to", "<=", $overtimeRequest->to); + }); + } + + protected static function newFactory(): OvertimeRequestFactory + { + return OvertimeRequestFactory::new(); + } +} diff --git a/app/Models/OvertimeRequestActivity.php b/app/Models/OvertimeRequestActivity.php new file mode 100644 index 00000000..53700c60 --- /dev/null +++ b/app/Models/OvertimeRequestActivity.php @@ -0,0 +1,45 @@ + OvertimeRequestState::class, + "to" => OvertimeRequestState::class, + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class) + ->withTrashed(); + } + + public function overtimeRequest(): BelongsTo + { + return $this->belongsTo(OvertimeRequest::class); + } + + protected static function newFactory(): OvertimeRequestActivityFactory + { + return OvertimeRequestActivityFactory::new(); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 584ac721..06e585ad 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -17,7 +17,6 @@ use Illuminate\Support\Facades\DB; use Laravel\Sanctum\HasApiTokens; use Spatie\Permission\Traits\HasRoles; -use Toby\Enums\EmploymentForm; use Toby\Enums\Role; use Toby\Enums\UserHistoryType; use Toby\Notifications\Notifiable as NotifiableInterface; @@ -45,7 +44,6 @@ class User extends Authenticatable implements NotifiableInterface protected $casts = [ "role" => Role::class, "last_active_at" => "datetime", - "employment_form" => EmploymentForm::class, "employment_date" => "date", ]; protected $hidden = [ @@ -71,11 +69,21 @@ public function vacationRequests(): HasMany return $this->hasMany(VacationRequest::class); } + public function overtimeRequests(): HasMany + { + return $this->hasMany(OvertimeRequest::class); + } + public function createdVacationRequests(): HasMany { return $this->hasMany(VacationRequest::class, "creator_id"); } + public function createdOvertimeRequests(): HasMany + { + return $this->hasMany(OvertimeRequest::class, "creator_id"); + } + public function vacations(): HasMany { return $this->hasMany(Vacation::class); diff --git a/app/Models/YearPeriod.php b/app/Models/YearPeriod.php index a4473ef5..edb010fa 100644 --- a/app/Models/YearPeriod.php +++ b/app/Models/YearPeriod.php @@ -16,6 +16,7 @@ * @property int $year * @property Collection $vacationLimits * @property Collection $vacationRequests + * @property Collection $overtimeRequests * @property Collection $holidays */ class YearPeriod extends Model @@ -47,6 +48,11 @@ public function vacationRequests(): HasMany return $this->hasMany(VacationRequest::class); } + public function overtimeRequests(): HasMany + { + return $this->hasMany(OvertimeRequest::class); + } + public function holidays(): HasMany { return $this->hasMany(Holiday::class); diff --git a/app/Observers/OvertimeRequestObserver.php b/app/Observers/OvertimeRequestObserver.php new file mode 100644 index 00000000..13ddccd0 --- /dev/null +++ b/app/Observers/OvertimeRequestObserver.php @@ -0,0 +1,24 @@ +yearPeriod->overtimeRequests()->count(); + $number = $count + 1; + + $overtime->name = "N/{$number}/{$overtime->yearPeriod->year}"; + } + + public function updating(OvertimeRequest $overtime): void + { + CacheQuery::forget("overtime{$overtime->user->id}"); + } +} diff --git a/app/Observers/VacationRequestObserver.php b/app/Observers/VacationRequestObserver.php index 8500a471..634cd79f 100644 --- a/app/Observers/VacationRequestObserver.php +++ b/app/Observers/VacationRequestObserver.php @@ -4,6 +4,7 @@ namespace Toby\Observers; +use Laragear\CacheQuery\Facades\CacheQuery; use Toby\Models\VacationRequest; class VacationRequestObserver @@ -15,4 +16,9 @@ public function creating(VacationRequest $vacationRequest): void $vacationRequest->name = "{$number}/{$vacationRequest->yearPeriod->year}"; } + + public function updating(VacationRequest $vacationRequest): void + { + CacheQuery::forget("vacations{$vacationRequest->user->id}"); + } } diff --git a/app/Policies/OvertimeRequestPolicy.php b/app/Policies/OvertimeRequestPolicy.php new file mode 100644 index 00000000..9434ba58 --- /dev/null +++ b/app/Policies/OvertimeRequestPolicy.php @@ -0,0 +1,61 @@ +hasPermissionTo("manageOvertimeAsAdministrativeApprover") || + $user->hasPermissionTo("manageOvertimeAsTechnicalApprover"); + } + + public function reject(User $user): bool + { + return $user->hasPermissionTo("manageOvertimeAsAdministrativeApprover") || + $user->hasPermissionTo("manageOvertimeAsTechnicalApprover"); + } + + public function cancel(User $user, OvertimeRequest $overtimeRequest): bool + { + if ($overtimeRequest->user->is($user) && $overtimeRequest->state->equals( + Created::class, + WaitingForTechnical::class, + )) { + return true; + } + + return $user->hasPermissionTo("manageOvertimeAsAdministrativeApprover") || + $user->hasPermissionTo("manageOvertimeAsTechnicalApprover"); + } + + public function settle(User $user, OvertimeRequest $overtimeRequest): bool + { + if ($overtimeRequest->user->is($user) && $overtimeRequest->state->equals( + Approved::class, + )) { + return true; + } + + return $user->hasPermissionTo("manageOvertimeAsAdministrativeApprover") || + $user->hasPermissionTo("manageOvertimeAsTechnicalApprover"); + } + + public function show(User $user, OvertimeRequest $overtimeRequest): bool + { + if ($overtimeRequest->user->is($user)) { + return true; + } + + return $user->hasPermissionTo("manageOvertimeAsAdministrativeApprover") || + $user->hasPermissionTo("manageOvertimeAsTechnicalApprover"); + } +} diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php new file mode 100644 index 00000000..424e6f34 --- /dev/null +++ b/app/Policies/UserPolicy.php @@ -0,0 +1,16 @@ +profile->employment_form->value === EmploymentForm::EmploymentContract->value; + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index b31603e1..da37876a 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -6,14 +6,20 @@ use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; use Toby\Models\Key; +use Toby\Models\OvertimeRequest; +use Toby\Models\User; use Toby\Models\VacationRequest; use Toby\Policies\KeyPolicy; +use Toby\Policies\OvertimeRequestPolicy; +use Toby\Policies\UserPolicy; use Toby\Policies\VacationRequestPolicy; class AuthServiceProvider extends ServiceProvider { protected $policies = [ + OvertimeRequest::class => OvertimeRequestPolicy::class, VacationRequest::class => VacationRequestPolicy::class, + User::class => UserPolicy::class, Key::class => KeyPolicy::class, ]; } diff --git a/app/Providers/ObserverServiceProvider.php b/app/Providers/ObserverServiceProvider.php index 54990af8..15217ad8 100644 --- a/app/Providers/ObserverServiceProvider.php +++ b/app/Providers/ObserverServiceProvider.php @@ -5,9 +5,11 @@ namespace Toby\Providers; use Illuminate\Support\ServiceProvider; +use Toby\Models\OvertimeRequest; use Toby\Models\User; use Toby\Models\VacationLimit; use Toby\Models\VacationRequest; +use Toby\Observers\OvertimeRequestObserver; use Toby\Observers\UserObserver; use Toby\Observers\VacationLimitObserver; use Toby\Observers\VacationRequestObserver; @@ -19,5 +21,6 @@ public function boot(): void User::observe(UserObserver::class); VacationRequest::observe(VacationRequestObserver::class); VacationLimit::observe(VacationLimitObserver::class); + OvertimeRequest::observe(OvertimeRequestObserver::class); } } diff --git a/app/States/OvertimeRequest/AcceptedByTechnical.php b/app/States/OvertimeRequest/AcceptedByTechnical.php new file mode 100644 index 00000000..b0160585 --- /dev/null +++ b/app/States/OvertimeRequest/AcceptedByTechnical.php @@ -0,0 +1,10 @@ +default(Created::class) + ->allowTransition(Created::class, Approved::class) + ->allowTransition(Created::class, WaitingForTechnical::class) + ->allowTransition(WaitingForTechnical::class, Rejected::class) + ->allowTransition(WaitingForTechnical::class, AcceptedByTechnical::class) + ->allowTransition(AcceptedByTechnical::class, Approved::class) + ->allowTransition([ + Created::class, + WaitingForTechnical::class, + AcceptedByTechnical::class, + Approved::class, + ], Cancelled::class) + ->allowTransition(Approved::class, Settled::class) + ->allowTransition([ + Created::class, + WaitingForTechnical::class, + AcceptedByTechnical::class, + Approved::class, + Settled::class, + ], Cancelled::class); + } + + public function label(): string + { + return __(static::$name); + } +} diff --git a/app/States/OvertimeRequest/Rejected.php b/app/States/OvertimeRequest/Rejected.php new file mode 100644 index 00000000..8be0d9f0 --- /dev/null +++ b/app/States/OvertimeRequest/Rejected.php @@ -0,0 +1,10 @@ +rules as $rule) { + $rule = $this->makeRule($rule); + + if (!$rule->check($overtimeRequest)) { + throw ValidationException::withMessages([ + "overtimeRequest" => $rule->errorMessage(), + ]); + } + } + } + + protected function makeRule(string $class): OvertimeRequestRule + { + return $this->container->make($class); + } +} diff --git a/app/Validation/Rules/OvertimeRequest/NoPendingOvertimeRequestInRange.php b/app/Validation/Rules/OvertimeRequest/NoPendingOvertimeRequestInRange.php new file mode 100644 index 00000000..a5c6367b --- /dev/null +++ b/app/Validation/Rules/OvertimeRequest/NoPendingOvertimeRequestInRange.php @@ -0,0 +1,26 @@ +user + ->overtimeRequests() + ->overlapsWith($overtimeRequest) + ->states(array_merge(OvertimeRequestStatesRetriever::pendingStates(), OvertimeRequestStatesRetriever::settledStates(), OvertimeRequestStatesRetriever::successStates())) + ->exists(); + } + + public function errorMessage(): string + { + return __("You have a pending request in this date range."); + } +} diff --git a/app/Validation/Rules/OvertimeRequest/OvertimeRequestRule.php b/app/Validation/Rules/OvertimeRequest/OvertimeRequestRule.php new file mode 100644 index 00000000..8966d5f7 --- /dev/null +++ b/app/Validation/Rules/OvertimeRequest/OvertimeRequestRule.php @@ -0,0 +1,14 @@ + $this->faker->randomElement(OvertimeRequestState::all()), + "to" => $this->faker->randomElement(OvertimeRequestState::all()), + ]; + } +} diff --git a/database/factories/OvertimeRequestFactory.php b/database/factories/OvertimeRequestFactory.php new file mode 100644 index 00000000..37e665cb --- /dev/null +++ b/database/factories/OvertimeRequestFactory.php @@ -0,0 +1,37 @@ +faker->dateTimeThisYear); + $to = $from->addHours($this->faker->numberBetween(1, 10)); + $hours = $to->diffInHours($from); + + return [ + "user_id" => User::factory(), + "creator_id" => fn(array $attributes): int => $attributes["user_id"], + "year_period_id" => YearPeriod::factory(), + "state" => $this->faker->randomElement(OvertimeRequestStatesRetriever::all()), + "from" => $from, + "to" => $to, + "hours" => $hours, + "settlement_type" => $this->faker->randomElement(SettlementType::cases())->value, + "comment" => $this->faker->boolean ? $this->faker->paragraph() : null, + ]; + } +} diff --git a/database/migrations/2024_06_06_080039_create_overtime_requests_table.php b/database/migrations/2024_06_06_080039_create_overtime_requests_table.php new file mode 100644 index 00000000..5967cd4d --- /dev/null +++ b/database/migrations/2024_06_06_080039_create_overtime_requests_table.php @@ -0,0 +1,35 @@ +id(); + $table->string("name"); + $table->foreignIdFor(User::class, "creator_id")->constrained("users")->cascadeOnDelete(); + $table->foreignIdFor(User::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(YearPeriod::class)->constrained()->cascadeOnDelete(); + $table->string("state")->nullable(); + $table->string("settlement_type"); + $table->boolean("settled")->default(false); + $table->dateTime("from"); + $table->dateTime("to"); + $table->integer("hours"); + $table->text("comment")->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists("overtime_requests"); + } +}; diff --git a/database/migrations/2024_06_06_140009_create_overtime_request_activities_table.php b/database/migrations/2024_06_06_140009_create_overtime_request_activities_table.php new file mode 100644 index 00000000..69328d35 --- /dev/null +++ b/database/migrations/2024_06_06_140009_create_overtime_request_activities_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignIdFor(OvertimeRequest::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(User::class)->nullable()->constrained()->cascadeOnDelete(); + $table->string("from")->nullable(); + $table->string("to"); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists("overtime_request_activities"); + } +}; diff --git a/database/seeders/DemoSeeder.php b/database/seeders/DemoSeeder.php index bc8222bf..2e78f7be 100644 --- a/database/seeders/DemoSeeder.php +++ b/database/seeders/DemoSeeder.php @@ -52,6 +52,20 @@ public function run(): void ]) ->create(); + User::factory([ + "email" => "jerzy.nowak@example.com", + "remember_token" => Str::random(10), + ]) + ->employee() + ->hasProfile([ + "first_name" => "Jerzy", + "last_name" => "Nowak", + "employment_form" => EmploymentForm::EmploymentContract, + "position" => "programista", + "employment_date" => Carbon::createFromDate(2021, 5, 10), + ]) + ->create(); + User::factory([ "email" => "anna.nowak@example.com", "remember_token" => Str::random(10), diff --git a/lang/pl.json b/lang/pl.json index fb471647..754624d8 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -46,6 +46,7 @@ "Request accepted.": "Wniosek zaakceptowany.", "Request rejected.": "Wniosek odrzucony.", "Request cancelled.": "Wniosek anulowany.", + "Overtime settled.": "Nadgodziny rozliczone.", "Sum:": "Suma:", "Date": "Data", "Day of week": "Dzień tygodnia", diff --git a/lang/pl/validation.php b/lang/pl/validation.php index 0cf194ee..f24f7492 100644 --- a/lang/pl/validation.php +++ b/lang/pl/validation.php @@ -188,6 +188,7 @@ ], "to" => [ "required_if" => "Data do jest wymagane.", + "after" => "Data końcowa musi być datą późniejszą od daty początkowej.", ], ], "attributes" => [ diff --git a/resources/js/Composables/permissionInfo.js b/resources/js/Composables/permissionInfo.js index ca0f1a21..bb62fb84 100644 --- a/resources/js/Composables/permissionInfo.js +++ b/resources/js/Composables/permissionInfo.js @@ -74,6 +74,21 @@ const permissionsInfo = [ 'value': 'skipRequestFlow', 'section': 'Urlopy', }, + { + 'name': 'Zarządzanie wnioskami jako przełożony administracyjny', + 'value': 'manageOvertimeAsAdministrativeApprover', + 'section': 'Nadgodziny', + }, + { + 'name': 'Zarządzanie wnioskami jako przełożony techniczny', + 'value': 'manageOvertimeAsTechnicalApprover', + 'section': 'Nadgodziny', + }, + { + 'name': 'Przeglądanie wszystkich wniosków', + 'value': 'listAllOvertimeRequests', + 'section': 'Nadgodziny', + }, { 'name': 'Nadchodzące i zaległe badania medycyny pracy', 'value': 'receiveUpcomingAndOverdueMedicalExamsNotification', diff --git a/resources/js/Composables/settlementTypeInfo.js b/resources/js/Composables/settlementTypeInfo.js new file mode 100644 index 00000000..9a0018ef --- /dev/null +++ b/resources/js/Composables/settlementTypeInfo.js @@ -0,0 +1,29 @@ +import ClockTimeFourOutlineIcon from 'vue-material-design-icons/ClockTimeFourOutline.vue' +import CurrencyUsdIcon from 'vue-material-design-icons/CurrencyUsd.vue' + +const types = [ + { + text: 'Godzinowe', + value: 'hours', + icon: ClockTimeFourOutlineIcon, + color: 'text-blue-500', + border: 'border-blue-500', + }, + { + text: 'Pieniężne', + value: 'money', + icon: CurrencyUsdIcon, + color: 'text-green-500', + border: 'border-green-500', + }, +] + +export default function useSettlementTypeInfo() { + const getTypes = () => types + const findType = value => types.find(type => type.value === value) + + return { + getTypes, + findType, + } +} diff --git a/resources/js/Composables/statusInfo.js b/resources/js/Composables/statusInfo.js index cd625956..06b039b5 100644 --- a/resources/js/Composables/statusInfo.js +++ b/resources/js/Composables/statusInfo.js @@ -5,6 +5,7 @@ import { HandThumbDownIcon as OutlineHandThumbDownIcon, HandThumbUpIcon as OutlineHandThumbUpIcon, XMarkIcon as OutlineXMarkIcon, + BanknotesIcon as OutlineBanknotesIcon, } from '@heroicons/vue/24/outline' import { @@ -14,6 +15,7 @@ import { HandThumbDownIcon as SolidHandThumbDownIcon, HandThumbUpIcon as SolidHandThumbUpIcon, XMarkIcon as SolidXMarkIcon, + BanknotesIcon as BanknotesIcon, } from '@heroicons/vue/24/solid' const statuses = [ @@ -121,6 +123,19 @@ const statuses = [ color: 'text-gray-900', }, }, + { + text: 'Rozliczony', + value: 'settled', + outline: { + icon: OutlineBanknotesIcon, + foreground: 'text-white', + background: 'bg-green-500', + }, + solid: { + icon: BanknotesIcon, + color: 'text-green-500', + }, + }, ] export function useStatusInfo() { diff --git a/resources/js/Pages/OvertimeRequest/Create.vue b/resources/js/Pages/OvertimeRequest/Create.vue new file mode 100644 index 00000000..cfdc66d0 --- /dev/null +++ b/resources/js/Pages/OvertimeRequest/Create.vue @@ -0,0 +1,316 @@ + + +