Merge branch 'main' into states

# Conflicts:
#	app/Domain/CalendarGenerator.php
#	app/Domain/VacationRequestStateManager.php
#	app/Eloquent/Models/VacationRequest.php
#	app/Eloquent/Observers/VacationRequestObserver.php
#	composer.json
#	composer.lock
#	tests/Feature/VacationRequestTest.php
This commit is contained in:
Adrian Hopek 2022-02-24 09:38:50 +01:00
commit 61f7bbeca7
52 changed files with 2572 additions and 155 deletions

View File

@ -1,4 +1,4 @@
APP_NAME="Toby HR applicaiton" APP_NAME="Toby HR application"
APP_ENV=local APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true

View File

@ -15,6 +15,7 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void public function boot(): void
{ {
Carbon::macro("toDisplayString", fn() => $this->translatedFormat("j F Y")); Carbon::macro("toDisplayString", fn() => $this->translatedFormat("j F Y"));
Carbon::macro("toDisplayDate", fn() => $this->translatedFormat("d.m.Y"));
$selectedYearPeriodScope = $this->app->make(SelectedYearPeriodScope::class); $selectedYearPeriodScope = $this->app->make(SelectedYearPeriodScope::class);

View File

@ -10,22 +10,34 @@ use Toby\Domain\Events\VacationRequestAcceptedByTechnical;
use Toby\Domain\Events\VacationRequestApproved; use Toby\Domain\Events\VacationRequestApproved;
use Toby\Domain\Events\VacationRequestCancelled; use Toby\Domain\Events\VacationRequestCancelled;
use Toby\Domain\Events\VacationRequestCreated; use Toby\Domain\Events\VacationRequestCreated;
use Toby\Domain\Events\VacationRequestRejected;
use Toby\Domain\Events\VacationRequestStateChanged; use Toby\Domain\Events\VacationRequestStateChanged;
use Toby\Domain\Events\VacationRequestWaitsForAdminApproval;
use Toby\Domain\Events\VacationRequestWaitsForTechApproval;
use Toby\Domain\Listeners\CreateVacationRequestActivity; use Toby\Domain\Listeners\CreateVacationRequestActivity;
use Toby\Domain\Listeners\HandleAcceptedByAdministrativeVacationRequest; use Toby\Domain\Listeners\HandleAcceptedByAdministrativeVacationRequest;
use Toby\Domain\Listeners\HandleAcceptedByTechnicalVacationRequest; use Toby\Domain\Listeners\HandleAcceptedByTechnicalVacationRequest;
use Toby\Domain\Listeners\HandleApprovedVacationRequest; use Toby\Domain\Listeners\HandleApprovedVacationRequest;
use Toby\Domain\Listeners\HandleCancelledVacationRequest; use Toby\Domain\Listeners\HandleCancelledVacationRequest;
use Toby\Domain\Listeners\HandleCreatedVacationRequest; use Toby\Domain\Listeners\HandleCreatedVacationRequest;
use Toby\Domain\Listeners\SendApprovedVacationRequestNotification;
use Toby\Domain\Listeners\SendCancelledVacationRequestNotification;
use Toby\Domain\Listeners\SendCreatedVacationRequestNotification;
use Toby\Domain\Listeners\SendRejectedVacationRequestNotification;
use Toby\Domain\Listeners\SendWaitedForAdministrativeVacationRequestNotification;
use Toby\Domain\Listeners\SendWaitedForTechnicalVacationRequestNotification;
class EventServiceProvider extends ServiceProvider class EventServiceProvider extends ServiceProvider
{ {
protected $listen = [ protected $listen = [
VacationRequestStateChanged::class => [CreateVacationRequestActivity::class], VacationRequestStateChanged::class => [CreateVacationRequestActivity::class],
VacationRequestCreated::class => [HandleCreatedVacationRequest::class], VacationRequestCreated::class => [HandleCreatedVacationRequest::class, SendCreatedVacationRequestNotification::class],
VacationRequestAcceptedByTechnical::class => [HandleAcceptedByTechnicalVacationRequest::class], VacationRequestAcceptedByTechnical::class => [HandleAcceptedByTechnicalVacationRequest::class],
VacationRequestAcceptedByAdministrative::class => [HandleAcceptedByAdministrativeVacationRequest::class], VacationRequestAcceptedByAdministrative::class => [HandleAcceptedByAdministrativeVacationRequest::class],
VacationRequestApproved::class => [HandleApprovedVacationRequest::class], VacationRequestApproved::class => [HandleApprovedVacationRequest::class, SendApprovedVacationRequestNotification::class],
VacationRequestCancelled::class => [HandleCancelledVacationRequest::class], VacationRequestRejected::class => [SendRejectedVacationRequestNotification::class],
VacationRequestCancelled::class => [HandleCancelledVacationRequest::class, SendCancelledVacationRequestNotification::class],
VacationRequestWaitsForTechApproval::class => [SendWaitedForTechnicalVacationRequestNotification::class],
VacationRequestWaitsForAdminApproval::class => [SendWaitedForAdministrativeVacationRequestNotification::class],
]; ];
} }

View File

@ -4,10 +4,9 @@ declare(strict_types=1);
namespace Toby\Domain; namespace Toby\Domain;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
use Carbon\CarbonPeriod; use Carbon\CarbonPeriod;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Toby\Eloquent\Helpers\YearPeriodRetriever; use Toby\Eloquent\Helpers\YearPeriodRetriever;
use Toby\Eloquent\Models\Vacation; use Toby\Eloquent\Models\Vacation;
@ -20,33 +19,16 @@ class CalendarGenerator
) { ) {
} }
public function generate(YearPeriod $yearPeriod, string $month): array public function generate(Carbon $month): array
{ {
$date = CarbonImmutable::create($yearPeriod->year, $this->monthNameToNumber($month)); $period = CarbonPeriod::create($month->copy()->startOfMonth(), $month->copy()->endOfMonth());
$period = CarbonPeriod::create($date->startOfMonth(), $date->endOfMonth()); $yearPeriod = YearPeriod::findByYear($month->year);
$holidays = $yearPeriod->holidays()->pluck("date"); $holidays = $yearPeriod->holidays()->pluck("date");
return $this->generateCalendar($period, $holidays); return $this->generateCalendar($period, $holidays);
} }
protected function monthNameToNumber($name): int
{
return match ($name) {
default => CarbonInterface::JANUARY,
"february" => CarbonInterface::FEBRUARY,
"march" => CarbonInterface::MARCH,
"april" => CarbonInterface::APRIL,
"may" => CarbonInterface::MAY,
"june" => CarbonInterface::JUNE,
"july" => CarbonInterface::JULY,
"august" => CarbonInterface::AUGUST,
"september" => CarbonInterface::SEPTEMBER,
"october" => CarbonInterface::OCTOBER,
"november" => CarbonInterface::NOVEMBER,
"december" => CarbonInterface::DECEMBER,
};
}
protected function generateCalendar(CarbonPeriod $period, Collection $holidays): array protected function generateCalendar(CarbonPeriod $period, Collection $holidays): array
{ {
$calendar = []; $calendar = [];

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Toby\Domain\Enums;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
enum Month: string
{
case January = "january";
case February = "february";
case March = "march";
case April = "april";
case May = "may";
case June = "june";
case July = "july";
case August = "august";
case September = "september";
case October = "october";
case November = "november";
case December = "december";
public function toCarbonNumber(): int
{
return match ($this) {
self::January => CarbonInterface::JANUARY,
self::February => CarbonInterface::FEBRUARY,
self::March => CarbonInterface::MARCH,
self::April => CarbonInterface::APRIL,
self::May => CarbonInterface::MAY,
self::June => CarbonInterface::JUNE,
self::July => CarbonInterface::JULY,
self::August => CarbonInterface::AUGUST,
self::September => CarbonInterface::SEPTEMBER,
self::October => CarbonInterface::OCTOBER,
self::November => CarbonInterface::NOVEMBER,
self::December => CarbonInterface::DECEMBER,
};
}
public static function current(): Month
{
return Month::from(Str::lower(Carbon::now()->englishMonth));
}
public static function fromNameOrCurrent(string $name): Month
{
return Month::tryFrom($name) ?? Month::current();
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Toby\Domain\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Toby\Eloquent\Models\VacationRequest;
class VacationRequestRejected
{
use Dispatchable;
use SerializesModels;
public function __construct(
public VacationRequest $vacationRequest,
) {
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Toby\Domain\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Toby\Eloquent\Models\VacationRequest;
class VacationRequestWaitsForAdminApproval
{
use Dispatchable;
use SerializesModels;
public function __construct(
public VacationRequest $vacationRequest,
) {
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Toby\Domain\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Toby\Eloquent\Models\VacationRequest;
class VacationRequestWaitsForTechApproval
{
use Dispatchable;
use SerializesModels;
public function __construct(
public VacationRequest $vacationRequest,
) {
}
}

View File

@ -20,6 +20,12 @@ class HandleCreatedVacationRequest
{ {
$vacationRequest = $event->vacationRequest; $vacationRequest = $event->vacationRequest;
if ($vacationRequest->hasFlowSkipped()) {
$this->stateManager->approve($vacationRequest);
return;
}
if ($this->configRetriever->needsTechnicalApproval($vacationRequest->type)) { if ($this->configRetriever->needsTechnicalApproval($vacationRequest->type)) {
$this->stateManager->waitForTechnical($vacationRequest); $this->stateManager->waitForTechnical($vacationRequest);

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Toby\Domain\Listeners;
use Illuminate\Support\Collection;
use Toby\Domain\Enums\Role;
use Toby\Domain\Events\VacationRequestApproved;
use Toby\Domain\Notifications\VacationRequestApprovedNotification;
use Toby\Eloquent\Models\User;
class SendApprovedVacationRequestNotification
{
public function __construct(
) {
}
public function handle(VacationRequestApproved $event): void
{
foreach ($this->getUsersForNotifications() as $user) {
$user->notify(new VacationRequestApprovedNotification($event->vacationRequest, $user));
}
$event->vacationRequest->user->notify(new VacationRequestApprovedNotification($event->vacationRequest, $event->vacationRequest->user));
}
protected function getUsersForNotifications(): Collection
{
return User::query()
->whereIn("role", [Role::TechnicalApprover, Role::AdministrativeApprover])
->get();
}
}

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Toby\Domain\Listeners;
use Illuminate\Support\Collection;
use Toby\Domain\Enums\Role;
use Toby\Domain\Events\VacationRequestCancelled;
use Toby\Domain\Notifications\VacationRequestCancelledNotification;
use Toby\Eloquent\Models\User;
class SendCancelledVacationRequestNotification
{
public function __construct(
) {
}
public function handle(VacationRequestCancelled $event): void
{
foreach ($this->getUsersForNotifications() as $user) {
$user->notify(new VacationRequestCancelledNotification($event->vacationRequest, $user));
}
$event->vacationRequest->user->notify(new VacationRequestCancelledNotification($event->vacationRequest, $event->vacationRequest->user));
}
protected function getUsersForNotifications(): Collection
{
return User::query()
->whereIn("role", [Role::TechnicalApprover, Role::AdministrativeApprover])
->get();
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Toby\Domain\Listeners;
use Toby\Domain\Events\VacationRequestCreated;
use Toby\Domain\Notifications\VacationRequestCreatedNotification;
use Toby\Domain\Notifications\VacationRequestCreatedOnEmployeeBehalf;
class SendCreatedVacationRequestNotification
{
public function __construct(
) {
}
public function handle(VacationRequestCreated $event): void
{
$vacationRequest = $event->vacationRequest;
if ($vacationRequest->creator->is($vacationRequest->user)) {
$vacationRequest->user->notify(new VacationRequestCreatedNotification($vacationRequest));
} else {
$vacationRequest->user->notify(new VacationRequestCreatedOnEmployeeBehalf($vacationRequest));
}
}
}

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Toby\Domain\Listeners;
use Illuminate\Support\Collection;
use Toby\Domain\Enums\Role;
use Toby\Domain\Events\VacationRequestRejected;
use Toby\Domain\Notifications\VacationRequestRejectedNotification;
use Toby\Eloquent\Models\User;
class SendRejectedVacationRequestNotification
{
public function __construct(
) {
}
public function handle(VacationRequestRejected $event): void
{
foreach ($this->getUsersForNotifications() as $user) {
$user->notify(new VacationRequestRejectedNotification($event->vacationRequest, $user));
}
$event->vacationRequest->user->notify(new VacationRequestRejectedNotification($event->vacationRequest, $event->vacationRequest->user));
}
protected function getUsersForNotifications(): Collection
{
return User::query()
->whereIn("role", [Role::TechnicalApprover, Role::AdministrativeApprover])
->get();
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Toby\Domain\Listeners;
use Illuminate\Support\Collection;
use Toby\Domain\Enums\Role;
use Toby\Domain\Events\VacationRequestWaitsForAdminApproval;
use Toby\Domain\Notifications\VacationRequestWaitsForAdminApprovalNotification;
use Toby\Eloquent\Models\User;
class SendWaitedForAdministrativeVacationRequestNotification
{
public function __construct(
) {
}
public function handle(VacationRequestWaitsForAdminApproval $event): void
{
foreach ($this->getUsersForNotifications() as $user) {
$user->notify(new VacationRequestWaitsForAdminApprovalNotification($event->vacationRequest, $user));
}
}
protected function getUsersForNotifications(): Collection
{
return User::query()
->where("role", [Role::AdministrativeApprover])
->get();
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Toby\Domain\Listeners;
use Illuminate\Support\Collection;
use Toby\Domain\Enums\Role;
use Toby\Domain\Events\VacationRequestWaitsForTechApproval;
use Toby\Domain\Notifications\VacationRequestWaitsForTechApprovalNotification;
use Toby\Eloquent\Models\User;
class SendWaitedForTechnicalVacationRequestNotification
{
public function __construct(
) {
}
public function handle(VacationRequestWaitsForTechApproval $event): void
{
foreach ($this->getUsersForNotifications() as $user) {
$user->notify(new VacationRequestWaitsForTechApprovalNotification($event->vacationRequest, $user));
}
}
protected function getUsersForNotifications(): Collection
{
return User::query()
->where("role", [Role::TechnicalApprover])
->get();
}
}

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Toby\Domain\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use InvalidArgumentException;
use Toby\Eloquent\Models\User;
use Toby\Eloquent\Models\VacationRequest;
class VacationRequestApprovedNotification extends Notification
{
use Queueable;
public function __construct(
protected VacationRequest $vacationRequest,
protected User $user,
) {
}
public function via(): array
{
return ["mail"];
}
/**
* @throws InvalidArgumentException
*/
public function toMail(): MailMessage
{
$url = route(
"vacation.requests.show",
[
"vacationRequest" => $this->vacationRequest,
],
);
return $this->buildMailMessage($url);
}
protected function buildMailMessage(string $url): MailMessage
{
$user = $this->user->first_name;
$title = $this->vacationRequest->name;
$type = $this->vacationRequest->type->label();
$from = $this->vacationRequest->from->toDisplayDate();
$to = $this->vacationRequest->to->toDisplayDate();
$days = $this->vacationRequest->vacations()->count();
$requester = $this->vacationRequest->user->fullName;
return (new MailMessage())
->greeting(__("Hi :user!", [
"user" => $user,
]))
->subject(__("Vacation request :title has been approved", [
"title" => $title,
]))
->line(__("The vacation request :title for user :requester has been approved.", [
"title" => $title,
"requester" => $requester,
]))
->line(__("Vacation type: :type", [
"type" => $type,
]))
->line(__("From :from to :to (number of days: :days)", [
"from" => $from,
"to" => $to,
"days" => $days,
]))
->action(__("Click here for details"), $url);
}
}

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Toby\Domain\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use InvalidArgumentException;
use Toby\Eloquent\Models\User;
use Toby\Eloquent\Models\VacationRequest;
class VacationRequestCancelledNotification extends Notification
{
use Queueable;
public function __construct(
protected VacationRequest $vacationRequest,
protected User $user,
) {
}
public function via(): array
{
return ["mail"];
}
/**
* @throws InvalidArgumentException
*/
public function toMail(): MailMessage
{
$url = route(
"vacation.requests.show",
[
"vacationRequest" => $this->vacationRequest,
],
);
return $this->buildMailMessage($url);
}
protected function buildMailMessage(string $url): MailMessage
{
$user = $this->user->first_name;
$title = $this->vacationRequest->name;
$type = $this->vacationRequest->type->label();
$from = $this->vacationRequest->from->toDisplayDate();
$to = $this->vacationRequest->to->toDisplayDate();
$days = $this->vacationRequest->vacations()->count();
$requester = $this->vacationRequest->user->fullName;
return (new MailMessage())
->greeting(__("Hi :user!", [
"user" => $user,
]))
->subject(__("Vacation request :title has been cancelled", [
"title" => $title,
]))
->line(__("The vacation request :title for user :requester has been cancelled.", [
"title" => $title,
"requester" => $requester,
]))
->line(__("Vacation type: :type", [
"type" => $type,
]))
->line(__("From :from to :to (number of days: :days)", [
"from" => $from,
"to" => $to,
"days" => $days,
]))
->action(__("Click here for details"), $url);
}
}

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Toby\Domain\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use InvalidArgumentException;
use Toby\Eloquent\Models\VacationRequest;
class VacationRequestCreatedNotification extends Notification
{
use Queueable;
public function __construct(
protected VacationRequest $vacationRequest,
) {
}
public function via(): array
{
return ["mail"];
}
/**
* @throws InvalidArgumentException
*/
public function toMail(): MailMessage
{
$url = route(
"vacation.requests.show",
[
"vacationRequest" => $this->vacationRequest,
],
);
return $this->buildMailMessage($url);
}
protected function buildMailMessage(string $url): MailMessage
{
$user = $this->vacationRequest->user->first_name;
$title = $this->vacationRequest->name;
$type = $this->vacationRequest->type->label();
$from = $this->vacationRequest->from->toDisplayDate();
$to = $this->vacationRequest->to->toDisplayDate();
$days = $this->vacationRequest->vacations()->count();
$appName = config("app.name");
return (new MailMessage())
->greeting(__("Hi :user!", [
"user" => $user,
]))
->subject(__("Vacation request :title has been created", [
"title" => $title,
]))
->line(__("The vacation request :title has been created correctly in the :appName.", [
"title" => $title,
"appName" => $appName,
]))
->line(__("Vacation type: :type", [
"type" => $type,
]))
->line(__("From :from to :to (number of days: :days)", [
"from" => $from,
"to" => $to,
"days" => $days,
]))
->action(__("Click here for details"), $url);
}
}

View File

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Toby\Domain\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use InvalidArgumentException;
use Toby\Eloquent\Models\VacationRequest;
class VacationRequestCreatedOnEmployeeBehalf extends Notification
{
use Queueable;
public function __construct(
protected VacationRequest $vacationRequest,
) {
}
public function via(): array
{
return ["mail"];
}
/**
* @throws InvalidArgumentException
*/
public function toMail(): MailMessage
{
$url = route(
"vacation.requests.show",
[
"vacationRequest" => $this->vacationRequest,
],
);
return $this->buildMailMessage($url);
}
protected function buildMailMessage(string $url): MailMessage
{
$creator = $this->vacationRequest->creator->fullName;
$user = $this->vacationRequest->user->first_name;
$title = $this->vacationRequest->name;
$type = $this->vacationRequest->type->label();
$from = $this->vacationRequest->from->toDisplayDate();
$to = $this->vacationRequest->to->toDisplayDate();
$days = $this->vacationRequest->vacations()->count();
$appName = config("app.name");
return (new MailMessage())
->greeting(__("Hi :user!", [
"user" => $user,
]))
->subject(__("Vacation request :title has been created on your behalf", [
"title" => $title,
]))
->line(__("The vacation request :title has been created correctly by user :creator on your behalf in the :appName.", [
"title" => $title,
"appName" => $appName,
"creator" => $creator,
]))
->line(__("Vacation type: :type", [
"type" => $type,
]))
->line(__("From :from to :to (number of days: :days)", [
"from" => $from,
"to" => $to,
"days" => $days,
]))
->action(__("Click here for details"), $url);
}
}

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Toby\Domain\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use InvalidArgumentException;
use Toby\Eloquent\Models\User;
use Toby\Eloquent\Models\VacationRequest;
class VacationRequestRejectedNotification extends Notification
{
use Queueable;
public function __construct(
protected VacationRequest $vacationRequest,
protected User $user,
) {
}
public function via(): array
{
return ["mail"];
}
/**
* @throws InvalidArgumentException
*/
public function toMail(): MailMessage
{
$url = route(
"vacation.requests.show",
[
"vacationRequest" => $this->vacationRequest,
],
);
return $this->buildMailMessage($url);
}
protected function buildMailMessage(string $url): MailMessage
{
$user = $this->user->first_name;
$title = $this->vacationRequest->name;
$type = $this->vacationRequest->type->label();
$from = $this->vacationRequest->from->toDisplayDate();
$to = $this->vacationRequest->to->toDisplayDate();
$days = $this->vacationRequest->vacations()->count();
$requester = $this->vacationRequest->user->fullName;
return (new MailMessage())
->greeting(__("Hi :user!", [
"user" => $user,
]))
->subject(__("Vacation request :title has been rejected", [
"title" => $title,
]))
->line(__("The vacation request :title for user :requester has been rejected.", [
"title" => $title,
"requester" => $requester,
]))
->line(__("Vacation type: :type", [
"type" => $type,
]))
->line(__("From :from to :to (number of days: :days)", [
"from" => $from,
"to" => $to,
"days" => $days,
]))
->action(__("Click here for details"), $url);
}
}

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Toby\Domain\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use InvalidArgumentException;
use Toby\Eloquent\Models\User;
use Toby\Eloquent\Models\VacationRequest;
class VacationRequestWaitsForAdminApprovalNotification extends Notification
{
use Queueable;
public function __construct(
protected VacationRequest $vacationRequest,
protected User $user,
) {
}
public function via(): array
{
return ["mail"];
}
/**
* @throws InvalidArgumentException
*/
public function toMail(): MailMessage
{
$url = route(
"vacation.requests.show",
[
"vacationRequest" => $this->vacationRequest,
],
);
return $this->buildMailMessage($url);
}
protected function buildMailMessage(string $url): MailMessage
{
$user = $this->user->first_name;
$requester = $this->vacationRequest->user->fullName;
$title = $this->vacationRequest->name;
$type = $this->vacationRequest->type->label();
$from = $this->vacationRequest->from->toDisplayDate();
$to = $this->vacationRequest->to->toDisplayDate();
$days = $this->vacationRequest->vacations()->count();
return (new MailMessage())
->greeting(__("Hi :user!", [
"user" => $user,
]))
->subject(__("Vacation request :title is waiting for your approval", [
"title" => $title,
]))
->line(__("The vacation request :title from user: :requester is waiting for your approval.", [
"title" => $title,
"requester" => $requester,
]))
->line(__("Vacation type: :type", [
"type" => $type,
]))
->line(__("From :from to :to (number of days: :days)", [
"from" => $from,
"to" => $to,
"days" => $days,
]))
->action(__("Click here for details"), $url);
}
}

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Toby\Domain\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use InvalidArgumentException;
use Toby\Eloquent\Models\User;
use Toby\Eloquent\Models\VacationRequest;
class VacationRequestWaitsForTechApprovalNotification extends Notification
{
use Queueable;
public function __construct(
protected VacationRequest $vacationRequest,
protected User $user,
) {
}
public function via(): array
{
return ["mail"];
}
/**
* @throws InvalidArgumentException
*/
public function toMail(): MailMessage
{
$url = route(
"vacation.requests.show",
[
"vacationRequest" => $this->vacationRequest,
],
);
return $this->buildMailMessage($url);
}
protected function buildMailMessage(string $url): MailMessage
{
$user = $this->user->first_name;
$requester = $this->vacationRequest->user->fullName;
$title = $this->vacationRequest->name;
$type = $this->vacationRequest->type->label();
$from = $this->vacationRequest->from->toDisplayDate();
$to = $this->vacationRequest->to->toDisplayDate();
$days = $this->vacationRequest->vacations()->count();
return (new MailMessage())
->greeting(__("Hi :user!", [
"user" => $user,
]))
->subject(__("Vacation request :title is waiting for your approval", [
"title" => $title,
]))
->line(__("The vacation request :title from user: :requester is waiting for your approval.", [
"title" => $title,
"requester" => $requester,
]))
->line(__("Vacation type: :type", [
"type" => $type,
]))
->line(__("From :from to :to (number of days: :days)", [
"from" => $from,
"to" => $to,
"days" => $days,
]))
->action(__("Click here for details"), $url);
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Toby\Domain;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
use Toby\Eloquent\Models\User;
class TimesheetExport implements WithMultipleSheets
{
protected Collection $users;
protected Carbon $month;
public function sheets(): array
{
return $this->users
->map(fn(User $user) => new TimesheetPerUserSheet($user, $this->month))
->toArray();
}
public function forUsers(Collection $users): static
{
$this->users = $users;
return $this;
}
public function forMonth(Carbon $month): static
{
$this->month = $month;
return $this;
}
}

View File

@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
namespace Toby\Domain;
use Carbon\CarbonInterface;
use Carbon\CarbonPeriod;
use Generator;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromGenerator;
use Maatwebsite\Excel\Concerns\RegistersEventListeners;
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
use Maatwebsite\Excel\Concerns\WithEvents;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\WithTitle;
use Maatwebsite\Excel\Events\AfterSheet;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Toby\Domain\Enums\VacationRequestState;
use Toby\Domain\Enums\VacationType;
use Toby\Eloquent\Models\Holiday;
use Toby\Eloquent\Models\User;
use Toby\Eloquent\Models\Vacation;
class TimesheetPerUserSheet implements WithTitle, WithHeadings, WithEvents, WithStyles, WithStrictNullComparison, ShouldAutoSize, FromGenerator
{
use RegistersEventListeners;
protected const HOURS_PER_DAY = 8;
protected const START_HOUR = 8;
protected const END_HOUR = 16;
public function __construct(
protected User $user,
protected Carbon $month,
) {
}
public function title(): string
{
return $this->user->fullName;
}
public function headings(): array
{
$types = VacationType::cases();
$headings = [
__("Date"),
__("Day of week"),
__("Start date"),
__("End date"),
__("Worked hours"),
];
foreach ($types as $type) {
$headings[] = $type->label();
}
return $headings;
}
public function generator(): Generator
{
$period = CarbonPeriod::create($this->month->copy()->startOfMonth(), $this->month->copy()->endOfMonth());
$vacations = $this->getVacationsForPeriod($this->user, $period);
$holidays = $this->getHolidaysForPeriod($period);
foreach ($period as $day) {
$vacationsForDay = $vacations->get($day->toDateString(), new Collection());
$workedThisDay = $this->checkIfWorkedThisDay($day, $holidays, $vacationsForDay);
$row = [
Date::dateTimeToExcel($day),
$day->translatedFormat("l"),
$workedThisDay ? $this->toExcelTime(Carbon::createFromTime(static::START_HOUR)) : null,
$workedThisDay ? $this->toExcelTime(Carbon::createFromTime(static::END_HOUR)) : null,
$workedThisDay ? static::HOURS_PER_DAY : null,
];
foreach (VacationType::cases() as $type) {
$row[] = $vacationsForDay->has($type->value) ? static::HOURS_PER_DAY : null;
}
yield $row;
}
}
public function styles(Worksheet $sheet): void
{
$lastRow = $sheet->getHighestRow();
$lastColumn = $sheet->getHighestColumn();
$sheet->getStyle("A1:{$lastColumn}1")
->getFont()->setBold(true);
$sheet->getStyle("A1:{$lastColumn}1")
->getAlignment()
->setVertical(Alignment::VERTICAL_CENTER);
$sheet->getStyle("A1:{$lastColumn}1")
->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()
->setRGB("D9D9D9");
$sheet->getStyle("C1:{$lastColumn}{$lastRow}")
->getAlignment()
->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle("A2:A{$lastRow}")
->getNumberFormat()
->setFormatCode(NumberFormat::FORMAT_DATE_DDMMYYYY);
$sheet->getStyle("C1:D{$lastRow}")
->getNumberFormat()
->setFormatCode(NumberFormat::FORMAT_DATE_TIME3);
$sheet->getStyle("A2:A{$lastRow}")
->getFont()
->setBold(true);
for ($i = 2; $i < $lastRow; $i++) {
$date = Date::excelToDateTimeObject($sheet->getCell("A{$i}")->getValue());
if (Carbon::createFromInterface($date)->isWeekend()) {
$sheet->getStyle("A{$i}:{$lastColumn}{$i}")
->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()
->setRGB("FEE2E2");
}
}
$sheet->getStyle("A1:{$lastColumn}{$lastRow}")
->getBorders()
->getAllBorders()
->setBorderStyle(Border::BORDER_THIN)
->getColor()
->setRGB("B7B7B7");
}
public static function afterSheet(AfterSheet $event): void
{
$sheet = $event->getSheet();
$lastRow = $sheet->getDelegate()->getHighestRow();
$sheet->append([
__("Sum:"),
null,
null,
null,
"=SUM(E2:E{$lastRow})",
]);
$lastRow++;
$sheet->getDelegate()->getStyle("A{$lastRow}")
->getAlignment()
->setHorizontal(Alignment::HORIZONTAL_RIGHT);
$sheet->getDelegate()->getStyle("A{$lastRow}")
->getFont()
->setBold(true);
$sheet->getDelegate()->getStyle("E{$lastRow}")
->getAlignment()
->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getDelegate()->mergeCells("A{$lastRow}:D{$lastRow}");
$sheet->getDelegate()->getStyle("A{$lastRow}:E{$lastRow}")
->getBorders()
->getAllBorders()
->setBorderStyle(Border::BORDER_THIN)
->getColor()
->setRGB("B7B7B7");
}
protected function getVacationsForPeriod(User $user, CarbonPeriod $period): Collection
{
return $user->vacations()
->with("vacationRequest")
->whereBetween("date", [$period->start, $period->end])
->whereRelation("vacationRequest", "state", VacationRequestState::Approved->value)
->get()
->groupBy(
[
fn(Vacation $vacation) => $vacation->date->toDateString(),
fn(Vacation $vacation) => $vacation->vacationRequest->type->value,
],
);
}
protected function getHolidaysForPeriod(CarbonPeriod $period): Collection
{
return Holiday::query()
->whereBetween("date", [$period->start, $period->end])
->pluck("date");
}
protected function toExcelTime(Carbon $time): float
{
$excelTimestamp = Date::dateTimeToExcel($time);
$excelDate = floor($excelTimestamp);
return $excelTimestamp - $excelDate;
}
protected function checkIfWorkedThisDay(CarbonInterface $day, Collection $holidays, Collection $vacations): bool
{
return $day->isWeekday() && $holidays->doesntContain($day) && $vacations->isEmpty();
}
}

View File

@ -11,7 +11,10 @@ use Toby\Domain\Events\VacationRequestAcceptedByTechnical;
use Toby\Domain\Events\VacationRequestApproved; use Toby\Domain\Events\VacationRequestApproved;
use Toby\Domain\Events\VacationRequestCancelled; use Toby\Domain\Events\VacationRequestCancelled;
use Toby\Domain\Events\VacationRequestCreated; use Toby\Domain\Events\VacationRequestCreated;
use Toby\Domain\Events\VacationRequestRejected;
use Toby\Domain\Events\VacationRequestStateChanged; use Toby\Domain\Events\VacationRequestStateChanged;
use Toby\Domain\Events\VacationRequestWaitsForAdminApproval;
use Toby\Domain\Events\VacationRequestWaitsForTechApproval;
use Toby\Domain\States\VacationRequest\AcceptedByAdministrative; use Toby\Domain\States\VacationRequest\AcceptedByAdministrative;
use Toby\Domain\States\VacationRequest\AcceptedByTechnical; use Toby\Domain\States\VacationRequest\AcceptedByTechnical;
use Toby\Domain\States\VacationRequest\Approved; use Toby\Domain\States\VacationRequest\Approved;
@ -48,6 +51,7 @@ class VacationRequestStateManager
public function reject(VacationRequest $vacationRequest, ?User $user = null): void public function reject(VacationRequest $vacationRequest, ?User $user = null): void
{ {
$this->changeState($vacationRequest, Rejected::class, $user); $this->changeState($vacationRequest, Rejected::class, $user);
$this->dispatcher->dispatch(new VacationRequestRejected($vacationRequest));
} }
public function cancel(VacationRequest $vacationRequest, ?User $user = null): void public function cancel(VacationRequest $vacationRequest, ?User $user = null): void
@ -74,11 +78,15 @@ class VacationRequestStateManager
public function waitForTechnical(VacationRequest $vacationRequest, ?User $user = null): void public function waitForTechnical(VacationRequest $vacationRequest, ?User $user = null): void
{ {
$this->changeState($vacationRequest, WaitingForTechnical::class, $user); $this->changeState($vacationRequest, WaitingForTechnical::class, $user);
$this->dispatcher->dispatch(new VacationRequestWaitsForTechApproval($vacationRequest));
} }
public function waitForAdministrative(VacationRequest $vacationRequest, ?User $user = null): void public function waitForAdministrative(VacationRequest $vacationRequest, ?User $user = null): void
{ {
$this->changeState($vacationRequest, WaitingForAdministrative::class, $user); $this->changeState($vacationRequest, WaitingForAdministrative::class, $user);
$this->dispatcher->dispatch(new VacationRequestWaitsForAdminApproval($vacationRequest));
} }
protected function changeState(VacationRequest $vacationRequest, string $state, ?User $user = null): void protected function changeState(VacationRequest $vacationRequest, string $state, ?User $user = null): void

View File

@ -58,6 +58,11 @@ class User extends Authenticatable
return $this->hasMany(VacationRequest::class); return $this->hasMany(VacationRequest::class);
} }
public function createdVacationRequests(): HasMany
{
return $this->hasMany(VacationRequest::class, "creator_id");
}
public function vacations(): HasMany public function vacations(): HasMany
{ {
return $this->hasMany(Vacation::class); return $this->hasMany(Vacation::class);

View File

@ -23,7 +23,9 @@ use Toby\Domain\States\VacationRequest\VacationRequestState;
* @property Carbon $from * @property Carbon $from
* @property Carbon $to * @property Carbon $to
* @property string $comment * @property string $comment
* @property bool $flow_skipped
* @property User $user * @property User $user
* @property User $creator
* @property YearPeriod $yearPeriod * @property YearPeriod $yearPeriod
* @property Collection $activities * @property Collection $activities
* @property Collection $vacations * @property Collection $vacations
@ -49,6 +51,11 @@ class VacationRequest extends Model
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, "creator_id");
}
public function yearPeriod(): BelongsTo public function yearPeriod(): BelongsTo
{ {
return $this->belongsTo(YearPeriod::class); return $this->belongsTo(YearPeriod::class);
@ -80,6 +87,11 @@ class VacationRequest extends Model
->where("to", ">=", $vacationRequest->from); ->where("to", ">=", $vacationRequest->from);
} }
public function hasFlowSkipped(): bool
{
return $this->flow_skipped;
}
protected static function newFactory(): VacationRequestFactory protected static function newFactory(): VacationRequestFactory
{ {
return VacationRequestFactory::new(); return VacationRequestFactory::new();

View File

@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Toby\Eloquent\Observers; namespace Toby\Eloquent\Observers;
use Illuminate\Contracts\Auth\Factory as Auth; use Illuminate\Contracts\Auth\Factory as Auth;
use Illuminate\Events\Dispatcher; use Illuminate\Contracts\Events\Dispatcher;
use Toby\Eloquent\Models\VacationRequest; use Toby\Eloquent\Models\VacationRequest;
class VacationRequestObserver class VacationRequestObserver

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Toby\Infrastructure\Http\Controllers;
use Illuminate\Support\Carbon;
use Maatwebsite\Excel\Facades\Excel;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Toby\Domain\Enums\Month;
use Toby\Domain\TimesheetExport;
use Toby\Eloquent\Helpers\YearPeriodRetriever;
use Toby\Eloquent\Models\User;
class TimesheetController extends Controller
{
public function __invoke(Month $month, YearPeriodRetriever $yearPeriodRetriever): BinaryFileResponse
{
$yearPeriod = $yearPeriodRetriever->selected();
$carbonMonth = Carbon::create($yearPeriod->year, $month->toCarbonNumber());
$users = User::query()
->orderBy("last_name")
->orderBy("first_name")
->get();
$filename = "{$carbonMonth->translatedFormat("F Y")}.xlsx";
$timesheet = (new TimesheetExport())
->forMonth($carbonMonth)
->forUsers($users);
return Excel::download($timesheet, $filename);
}
}

View File

@ -4,11 +4,10 @@ declare(strict_types=1);
namespace Toby\Infrastructure\Http\Controllers; namespace Toby\Infrastructure\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Inertia\Response; use Inertia\Response;
use Toby\Domain\CalendarGenerator; use Toby\Domain\CalendarGenerator;
use Toby\Domain\Enums\Month;
use Toby\Eloquent\Helpers\YearPeriodRetriever; use Toby\Eloquent\Helpers\YearPeriodRetriever;
use Toby\Eloquent\Models\User; use Toby\Eloquent\Models\User;
use Toby\Infrastructure\Http\Resources\UserResource; use Toby\Infrastructure\Http\Resources\UserResource;
@ -16,22 +15,25 @@ use Toby\Infrastructure\Http\Resources\UserResource;
class VacationCalendarController extends Controller class VacationCalendarController extends Controller
{ {
public function index( public function index(
Request $request,
YearPeriodRetriever $yearPeriodRetriever, YearPeriodRetriever $yearPeriodRetriever,
CalendarGenerator $calendarGenerator, CalendarGenerator $calendarGenerator,
?string $month = null,
): Response { ): Response {
$month = Str::lower($request->query("month", Carbon::now()->englishMonth)); $month = Month::fromNameOrCurrent((string)$month);
$yearPeriod = $yearPeriodRetriever->selected(); $yearPeriod = $yearPeriodRetriever->selected();
$carbonMonth = Carbon::create($yearPeriod->year, $month->toCarbonNumber());
$users = User::query() $users = User::query()
->orderBy("last_name") ->orderBy("last_name")
->orderBy("first_name") ->orderBy("first_name")
->get(); ->get();
$calendar = $calendarGenerator->generate($yearPeriod, $month); $calendar = $calendarGenerator->generate($carbonMonth);
return inertia("Calendar", [ return inertia("Calendar", [
"calendar" => $calendar, "calendar" => $calendar,
"currentMonth" => $month, "currentMonth" => $month->value,
"users" => UserResource::collection($users), "users" => UserResource::collection($users),
]); ]);
} }

View File

@ -8,7 +8,6 @@ use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response as LaravelResponse; use Illuminate\Http\Response as LaravelResponse;
use Illuminate\Support\Arr;
use Inertia\Response; use Inertia\Response;
use Toby\Domain\Enums\Role; use Toby\Domain\Enums\Role;
use Toby\Domain\Enums\VacationType; use Toby\Domain\Enums\VacationType;
@ -21,8 +20,10 @@ use Toby\Domain\VacationRequestStateManager;
use Toby\Domain\VacationRequestStatesRetriever; use Toby\Domain\VacationRequestStatesRetriever;
use Toby\Domain\Validation\VacationRequestValidator; use Toby\Domain\Validation\VacationRequestValidator;
use Toby\Eloquent\Helpers\YearPeriodRetriever; use Toby\Eloquent\Helpers\YearPeriodRetriever;
use Toby\Eloquent\Models\User;
use Toby\Eloquent\Models\VacationRequest; use Toby\Eloquent\Models\VacationRequest;
use Toby\Infrastructure\Http\Requests\VacationRequestRequest; use Toby\Infrastructure\Http\Requests\VacationRequestRequest;
use Toby\Infrastructure\Http\Resources\UserResource;
use Toby\Infrastructure\Http\Resources\VacationRequestActivityResource; use Toby\Infrastructure\Http\Resources\VacationRequestActivityResource;
use Toby\Infrastructure\Http\Resources\VacationRequestResource; use Toby\Infrastructure\Http\Resources\VacationRequestResource;
@ -61,10 +62,10 @@ class VacationRequestController extends Controller
"acceptAsAdministrative" => $vacationRequest->state->canTransitionTo(AcceptedByAdministrative::class) "acceptAsAdministrative" => $vacationRequest->state->canTransitionTo(AcceptedByAdministrative::class)
&& $user === Role::AdministrativeApprover, && $user === Role::AdministrativeApprover,
"reject" => $vacationRequest->state->canTransitionTo(Rejected::class) "reject" => $vacationRequest->state->canTransitionTo(Rejected::class)
&& in_array($user->role, [Role::TechnicalApprover, Role::AdministrativeApprover]), && in_array($user->role, [Role::TechnicalApprover, Role::AdministrativeApprover], true),
"cancel" => $vacationRequest->state->canTransitionTo(Cancelled::class) "cancel" => $vacationRequest->state->canTransitionTo(Cancelled::class)
&& $user === Role::AdministrativeApprover, && $user === Role::AdministrativeApprover,
] ],
]); ]);
} }
@ -79,8 +80,14 @@ class VacationRequestController extends Controller
public function create(): Response public function create(): Response
{ {
$users = User::query()
->orderBy("last_name")
->orderBy("first_name")
->get();
return inertia("VacationRequest/Create", [ return inertia("VacationRequest/Create", [
"vacationTypes" => VacationType::casesToSelect(), "vacationTypes" => VacationType::casesToSelect(),
"users" => UserResource::collection($users),
]); ]);
} }
@ -91,7 +98,7 @@ class VacationRequestController extends Controller
VacationDaysCalculator $vacationDaysCalculator, VacationDaysCalculator $vacationDaysCalculator,
): RedirectResponse { ): RedirectResponse {
/** @var VacationRequest $vacationRequest */ /** @var VacationRequest $vacationRequest */
$vacationRequest = $request->user()->vacationRequests()->make($request->data()); $vacationRequest = $request->user()->createdVacationRequests()->make($request->data());
$vacationRequestValidator->validate($vacationRequest); $vacationRequestValidator->validate($vacationRequest);
$vacationRequest->save(); $vacationRequest->save();

View File

@ -16,9 +16,11 @@ class VacationRequestRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
"user" => ["required", "exists:users,id"],
"type" => ["required", new Enum(VacationType::class)], "type" => ["required", new Enum(VacationType::class)],
"from" => ["required", "date_format:Y-m-d", new YearPeriodExists()], "from" => ["required", "date_format:Y-m-d", new YearPeriodExists()],
"to" => ["required", "date_format:Y-m-d", new YearPeriodExists()], "to" => ["required", "date_format:Y-m-d", new YearPeriodExists()],
"flowSkipped" => ["nullable", "boolean"],
"comment" => ["nullable"], "comment" => ["nullable"],
]; ];
} }
@ -28,11 +30,13 @@ class VacationRequestRequest extends FormRequest
$from = $this->get("from"); $from = $this->get("from");
return [ return [
"user_id" => $this->get("user"),
"type" => $this->get("type"), "type" => $this->get("type"),
"from" => $from, "from" => $from,
"to" => $this->get("to"), "to" => $this->get("to"),
"year_period_id" => YearPeriod::findByYear(Carbon::create($from)->year)->id, "year_period_id" => YearPeriod::findByYear(Carbon::create($from)->year)->id,
"comment" => $this->get("comment"), "comment" => $this->get("comment"),
"flow_skipped" => $this->boolean("flowSkipped"),
]; ];
} }
} }

View File

@ -13,7 +13,7 @@ class VacationRequestActivityResource extends JsonResource
public function toArray($request): array public function toArray($request): array
{ {
return [ return [
"date" => $this->created_at->format("d.m.Y"), "date" => $this->created_at->toDisplayDate(),
"time" => $this->created_at->format("H:i"), "time" => $this->created_at->format("H:i"),
"user" => $this->user ? $this->user->fullName : __("System"), "user" => $this->user ? $this->user->fullName : __("System"),
"state" => $this->to, "state" => $this->to,

View File

@ -19,7 +19,8 @@
"laravel/tinker": "^2.5", "laravel/tinker": "^2.5",
"lasserafn/php-initial-avatar-generator": "^4.2", "lasserafn/php-initial-avatar-generator": "^4.2",
"spatie/laravel-google-calendar": "^3.5", "spatie/laravel-google-calendar": "^3.5",
"spatie/laravel-model-states": "^2.1" "spatie/laravel-model-states": "^2.1",
"maatwebsite/excel": "^3.1"
}, },
"require-dev": { "require-dev": {
"blumilksoftware/codestyle": "^0.9.0", "blumilksoftware/codestyle": "^0.9.0",

698
composer.lock generated

File diff suppressed because it is too large Load Diff

104
config/excel.php Normal file
View File

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
use Maatwebsite\Excel\DefaultValueBinder;
use Maatwebsite\Excel\Excel;
return [
"exports" => [
"chunk_size" => 1000,
"pre_calculate_formulas" => false,
"strict_null_comparison" => false,
"csv" => [
"delimiter" => ",",
"enclosure" => '"',
"line_ending" => PHP_EOL,
"use_bom" => false,
"include_separator_line" => false,
"excel_compatibility" => false,
"output_encoding" => "",
],
"properties" => [
"creator" => "",
"lastModifiedBy" => "",
"title" => "",
"description" => "",
"subject" => "",
"keywords" => "",
"category" => "",
"manager" => "",
"company" => "",
],
],
"imports" => [
"read_only" => true,
"ignore_empty" => false,
"heading_row" => [
"formatter" => "slug",
],
"csv" => [
"delimiter" => ",",
"enclosure" => '"',
"escape_character" => "\\",
"contiguous" => false,
"input_encoding" => "UTF-8",
],
"properties" => [
"creator" => "",
"lastModifiedBy" => "",
"title" => "",
"description" => "",
"subject" => "",
"keywords" => "",
"category" => "",
"manager" => "",
"company" => "",
],
],
"extension_detector" => [
"xlsx" => Excel::XLSX,
"xlsm" => Excel::XLSX,
"xltx" => Excel::XLSX,
"xltm" => Excel::XLSX,
"xls" => Excel::XLS,
"xlt" => Excel::XLS,
"ods" => Excel::ODS,
"ots" => Excel::ODS,
"slk" => Excel::SLK,
"xml" => Excel::XML,
"gnumeric" => Excel::GNUMERIC,
"htm" => Excel::HTML,
"html" => Excel::HTML,
"csv" => Excel::CSV,
"tsv" => Excel::TSV,
"pdf" => Excel::DOMPDF,
],
"value_binder" => [
"default" => DefaultValueBinder::class,
],
"cache" => [
"driver" => "memory",
"batch" => [
"memory_limit" => 60000,
],
"illuminate" => [
"store" => null,
],
],
"transactions" => [
"handler" => "db",
"db" => [
"connection" => null,
],
],
"temporary_files" => [
"local_path" => storage_path("framework/cache/laravel-excel"),
"remote_disk" => null,
"remote_prefix" => null,
"force_resync_remote" => null,
],
];

View File

@ -35,7 +35,7 @@ return [
"name" => env("MAIL_FROM_NAME", "Example"), "name" => env("MAIL_FROM_NAME", "Example"),
], ],
"markdown" => [ "markdown" => [
"theme" => "default", "theme" => "mail",
"paths" => [ "paths" => [
resource_path("views/vendor/mail"), resource_path("views/vendor/mail"),
], ],

View File

@ -24,8 +24,9 @@ class VacationRequestFactory extends Factory
return [ return [
"user_id" => User::factory(), "user_id" => User::factory(),
"creator_id" => fn(array $attributes): int => $attributes["user_id"],
"year_period_id" => YearPeriod::factory(), "year_period_id" => YearPeriod::factory(),
"name" => fn(array $attributes) => $this->generateName($attributes), "name" => fn(array $attributes): string => $this->generateName($attributes),
"type" => $this->faker->randomElement(VacationType::cases()), "type" => $this->faker->randomElement(VacationType::cases()),
"state" => $this->faker->randomElement(VacationRequestStatesRetriever::all()), "state" => $this->faker->randomElement(VacationRequestStatesRetriever::all()),
"from" => $from, "from" => $from,

View File

@ -14,6 +14,7 @@ return new class() extends Migration {
Schema::create("vacation_requests", function (Blueprint $table): void { Schema::create("vacation_requests", function (Blueprint $table): void {
$table->id(); $table->id();
$table->string("name"); $table->string("name");
$table->foreignIdFor(User::class, "creator_id")->constrained("users")->cascadeOnDelete();
$table->foreignIdFor(User::class)->constrained()->cascadeOnDelete(); $table->foreignIdFor(User::class)->constrained()->cascadeOnDelete();
$table->foreignIdFor(YearPeriod::class)->constrained()->cascadeOnDelete(); $table->foreignIdFor(YearPeriod::class)->constrained()->cascadeOnDelete();
$table->string("type"); $table->string("type");
@ -21,6 +22,7 @@ return new class() extends Migration {
$table->date("from"); $table->date("from");
$table->date("to"); $table->date("to");
$table->text("comment")->nullable(); $table->text("comment")->nullable();
$table->boolean("flow_skipped")->default(false);
$table->timestamps(); $table->timestamps();
}); });
} }

View File

@ -77,6 +77,7 @@ class DatabaseSeeder extends Seeder
VacationRequest::factory() VacationRequest::factory()
->count(10) ->count(10)
->for($user) ->for($user)
->for($user, "creator")
->sequence(fn() => [ ->sequence(fn() => [
"year_period_id" => $yearPeriods->random()->id, "year_period_id" => $yearPeriods->random()->id,
]) ])

2
package-lock.json generated
View File

@ -1,5 +1,5 @@
{ {
"name": "toby", "name": "application",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {

View File

@ -0,0 +1,12 @@
import {computed} from 'vue'
import {usePage} from '@inertiajs/inertia-vue3'
export default function useCurrentYearPeriodInfo() {
const minDate = computed(() => new Date(usePage().props.value.years.current, 0, 1))
const maxDate = computed(() => new Date(usePage().props.value.years.current, 11, 31))
return {
minDate,
maxDate,
}
}

View File

@ -1,10 +1,20 @@
<template> <template>
<InertiaHead title="Kalendarz urlopów" /> <InertiaHead title="Kalendarz urlopów" />
<div class="bg-white shadow-md"> <div class="bg-white shadow-md">
<div class="p-4 sm:px-6"> <div class="flex justify-between items-center p-4 sm:px-6">
<h2 class="text-lg leading-6 font-medium text-gray-900"> <div>
Kalendarz urlopów <h2 class="text-lg leading-6 font-medium text-gray-900">
</h2> Kalendarz urlopów
</h2>
</div>
<div>
<a
:href="`/timesheet/${selectedMonth.value}`"
class="inline-flex items-center px-4 py-3 border border-transparent text-sm leading-4 font-medium rounded-md shadow-sm text-white bg-blumilk-600 hover:bg-blumilk-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blumilk-500"
>
Pobierz plik excel
</a>
</div>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-center table-fixed text-sm border border-gray-300"> <table class="w-full text-center table-fixed text-sm border border-gray-300">
@ -19,7 +29,7 @@
<MenuButton <MenuButton
class="inline-flex justify-center w-full rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blumilk-500" class="inline-flex justify-center w-full rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blumilk-500"
> >
{{ selectedMonth.name }} {{ selectedMonth.name }} {{ years.current }}
<ChevronDownIcon class="-mr-1 ml-2 h-5 w-5" /> <ChevronDownIcon class="-mr-1 ml-2 h-5 w-5" />
</MenuButton> </MenuButton>
</div> </div>
@ -42,8 +52,7 @@
v-slot="{ active }" v-slot="{ active }"
> >
<InertiaLink <InertiaLink
href="/vacation-calendar" :href="`/vacation-calendar/${month.value}`"
:data="{ month: month.value }"
:class="[active ? 'bg-gray-100 text-gray-900' : 'text-gray-700', 'flex w-full font-normal px-4 py-2 text-sm']" :class="[active ? 'bg-gray-100 text-gray-900' : 'text-gray-700', 'flex w-full font-normal px-4 py-2 text-sm']"
> >
{{ month.name }} {{ month.name }}
@ -151,6 +160,10 @@ export default {
type: String, type: String,
default: () => 'january', default: () => 'january',
}, },
years: {
type: Object,
default: () => null,
},
}, },
setup(props) { setup(props) {
const {getMonths, findMonth} = useMonthInfo() const {getMonths, findMonth} = useMonthInfo()

View File

@ -48,6 +48,7 @@
id="date" id="date"
v-model="form.date" v-model="form.date"
placeholder="Wybierz datę" placeholder="Wybierz datę"
:config="{minDate, maxDate}"
class="block w-full max-w-lg shadow-sm rounded-md sm:text-sm" class="block w-full max-w-lg shadow-sm rounded-md sm:text-sm"
:class="{ 'border-red-300 text-red-900 focus:outline-none focus:ring-red-500 focus:border-red-500': form.errors.date, 'focus:ring-blumilk-500 focus:border-blumilk-500 sm:text-sm border-gray-300': !form.errors.date }" :class="{ 'border-red-300 text-red-900 focus:outline-none focus:ring-red-500 focus:border-red-500': form.errors.date, 'focus:ring-blumilk-500 focus:border-blumilk-500 sm:text-sm border-gray-300': !form.errors.date }"
/> />
@ -81,8 +82,9 @@
</template> </template>
<script> <script>
import { useForm } from '@inertiajs/inertia-vue3' import {useForm} from '@inertiajs/inertia-vue3'
import FlatPickr from 'vue-flatpickr-component' import FlatPickr from 'vue-flatpickr-component'
import useCurrentYearPeriodInfo from '@/Composables/yearPeriodInfo'
export default { export default {
name: 'HolidayCreate', name: 'HolidayCreate',
@ -95,7 +97,13 @@ export default {
date: null, date: null,
}) })
return { form } const {minDate, maxDate} = useCurrentYearPeriodInfo()
return {
form,
minDate,
maxDate,
}
}, },
methods: { methods: {
createHoliday() { createHoliday() {

View File

@ -28,6 +28,76 @@
</div> </div>
</div> </div>
</div> </div>
<Listbox
v-model="form.user"
as="div"
class="sm:grid sm:grid-cols-3 py-4 items-center"
>
<ListboxLabel class="block text-sm font-medium text-gray-700">
Osoba składająca wniosek
</ListboxLabel>
<div class="mt-1 relative sm:mt-0 sm:col-span-2">
<ListboxButton
class="bg-white relative w-full max-w-lg border rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default sm:text-sm focus:ring-1"
:class="{ 'border-red-300 text-red-900 focus:outline-none focus:ring-red-500 focus:border-red-500': form.errors.type, 'focus:ring-blumilk-500 focus:border-blumilk-500 sm:text-sm border-gray-300': !form.errors.type }"
>
<span class="flex items-center">
<img
:src="form.user.avatar"
class="flex-shrink-0 h-6 w-6 rounded-full"
>
<span class="ml-3 block truncate">{{ form.user.name }}</span>
</span>
<span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<SelectorIcon class="h-5 w-5 text-gray-400" />
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 w-full max-w-lg bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm"
>
<ListboxOption
v-for="user in users.data"
:key="user.id"
v-slot="{ active, selected }"
as="template"
:value="user"
>
<li :class="[active ? 'text-white bg-blumilk-600' : 'text-gray-900', 'cursor-default select-none relative py-2 pl-3 pr-9']">
<div class="flex items-center">
<img
:src="user.avatar"
alt=""
class="flex-shrink-0 h-6 w-6 rounded-full"
>
<span :class="[selected ? 'font-semibold' : 'font-normal', 'ml-3 block truncate']">
{{ user.name }}
</span>
</div>
<span
v-if="selected"
:class="[active ? 'text-white' : 'text-blumilk-600', 'absolute inset-y-0 right-0 flex items-center pr-4']"
>
<CheckIcon class="h-5 w-5" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
<p
v-if="form.errors.type"
class="mt-2 text-sm text-red-600"
>
{{ form.errors.type }}
</p>
</div>
</Listbox>
<Listbox <Listbox
v-model="form.type" v-model="form.type"
as="div" as="div"
@ -161,6 +231,25 @@
/> />
</div> </div>
</div> </div>
<div class="sm:grid sm:grid-cols-3 py-4 items-center">
<label
for="flowSkipped"
class="block text-sm font-medium text-gray-700"
>
Natychmiastowo zatwierdź wniosek
</label>
<div class="mt-1 sm:mt-0 sm:col-span-2">
<Switch
id="flowSkipped"
v-model="form.flowSkipped"
:class="[form.flowSkipped ? 'bg-blumilk-500' : 'bg-gray-200', 'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blumilk-500']"
>
<span
:class="[form.flowSkipped ? 'translate-x-5' : 'translate-x-0', 'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200']"
/>
</Switch>
</div>
</div>
<div class="flex justify-end py-3"> <div class="flex justify-end py-3">
<div class="space-x-3"> <div class="space-x-3">
<InertiaLink <InertiaLink
@ -183,16 +272,18 @@
</template> </template>
<script> <script>
import {useForm, usePage} from '@inertiajs/inertia-vue3' import {useForm} from '@inertiajs/inertia-vue3'
import FlatPickr from 'vue-flatpickr-component' import FlatPickr from 'vue-flatpickr-component'
import {Listbox, ListboxButton, ListboxLabel, ListboxOption, ListboxOptions} from '@headlessui/vue' import {Listbox, ListboxButton, ListboxLabel, ListboxOption, ListboxOptions, Switch} from '@headlessui/vue'
import {CheckIcon, SelectorIcon, XCircleIcon} from '@heroicons/vue/solid' import {CheckIcon, SelectorIcon, XCircleIcon} from '@heroicons/vue/solid'
import {reactive, ref, computed} from 'vue' import {reactive, ref} from 'vue'
import axios from 'axios' import axios from 'axios'
import useCurrentYearPeriodInfo from '@/Composables/yearPeriodInfo'
export default { export default {
name: 'VacationRequestCreate', name: 'VacationRequestCreate',
components: { components: {
Switch,
FlatPickr, FlatPickr,
Listbox, Listbox,
ListboxButton, ListboxButton,
@ -204,6 +295,14 @@ export default {
XCircleIcon, XCircleIcon,
}, },
props: { props: {
auth: {
type: Object,
default: () => null,
},
users: {
type: Object,
default: () => null,
},
vacationTypes: { vacationTypes: {
type: Object, type: Object,
default: () => null, default: () => null,
@ -215,15 +314,16 @@ export default {
}, },
setup(props) { setup(props) {
const form = useForm({ const form = useForm({
user: props.users.data.find(user => user.id === props.auth.user.id),
from: null, from: null,
to: null, to: null,
type: props.vacationTypes[0], type: props.vacationTypes[0],
comment: null, comment: null,
flowSkipped: false,
}) })
const estimatedDays = ref([]) const estimatedDays = ref([])
const minDate = computed(() => new Date(usePage().props.value.years.current, 0, 1)) const {minDate, maxDate} = useCurrentYearPeriodInfo()
const maxDate = computed(() => new Date(usePage().props.value.years.current, 11, 31))
const disableDates = [ const disableDates = [
date => (date.getDay() === 0 || date.getDay() === 6), date => (date.getDay() === 0 || date.getDay() === 6),
@ -254,6 +354,7 @@ export default {
.transform(data => ({ .transform(data => ({
...data, ...data,
type: data.type.value, type: data.type.value,
user: data.user.id,
})) }))
.post('/vacation-requests') .post('/vacation-requests')
}, },

View File

@ -42,7 +42,35 @@
"Vacation limits have been updated.": "Limity urlopów zostały zaktualizowane.", "Vacation limits have been updated.": "Limity urlopów zostały zaktualizowane.",
"Vacation request has been created.": "Wniosek urlopowy został utworzony.", "Vacation request has been created.": "Wniosek urlopowy został utworzony.",
"Vacation request has been accepted.": "Wniosek urlopowy został zaakceptowany.", "Vacation request has been accepted.": "Wniosek urlopowy został zaakceptowany.",
"Vacation request has been approved.": "Wniosek urlopowy został zatwierdzony.",
"Vacation request has been rejected.": "Wniosek urlopowy został odrzucony.", "Vacation request has been rejected.": "Wniosek urlopowy został odrzucony.",
"Vacation request has been cancelled.": "Wniosek urlopowy został anulowany." "Vacation request has been cancelled.": "Wniosek urlopowy został anulowany.",
"Sum:": "Suma:",
"Date": "Data",
"Day of week": "Dzień tygodnia",
"Start date": "Data rozpoczęcia",
"End date": "Data zakończenia",
"Worked hours": "Liczba godzin",
"Hi :user!": "Cześć :user!",
"The vacation request :title has changed state to :state.": "Wniosek urlopowy :title zmienił status na :state.",
"Vacation request :title": "Wniosek urlopowy :title",
"Regards": "Z poważaniem",
"If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "Jeżeli masz problemy z kliknięciem przycisku \":actionText\", skopiuj i wklej poniższy adres w pasek przeglądarki:",
"All rights reserved.": "Wszelkie prawa zastrzeżone",
"Show vacation request": "Pokaż wniosek",
"Vacation request :title has been created" : "Wniosek :title został utworzony",
"The vacation request :title has been created correctly in the :appName.": "W systemie :appName został poprawnie utworzony wniosek urlopowy :title.",
"Vacation type: :type": "Rodzaj wniosku: :type",
"From :from to :to (number of days: :days)": "Od :from do :to (liczba dni: :days)",
"Click here for details": "Kliknij, aby zobaczyć szczegóły",
"Vacation request :title is waiting for your approval": "Wniosek urlopowy :title czeka na zaakceptowanie",
"The vacation request :title from user: :requester is waiting for your approval.": "Wniosek urlopowy :title od użytkownika :requester czeka na Twoją akceptację.",
"Vacation request :title has been approved": "Wniosek urlopowy :title został zatwierdzony",
"The vacation request :title for user :requester has been approved.": "Wniosek urlopowy :title od użytkownika :requester został zatwierdzony.",
"Vacation request :title has been cancelled": "Wniosek urlopowy :title został anulowany",
"The vacation request :title for user :requester has been cancelled.": "Wniosek urlopowy :title od użytkownika :requester został anulowany.",
"Vacation request :title has been rejected": "Wniosek urlopowy :title został odrzucony",
"The vacation request :title for user :requester has been rejected.": "Wniosek urlopowy :title od użytkownika :requester został odrzucony.",
"Vacation request :title has been created on your behalf": "Wniosek urlopowy :title został utworzony w Twoim imieniu",
"The vacation request :title has been created correctly by user :creator on your behalf in the :appName.": "W systemie :appName został poprawnie utworzony wniosek urlopowy :title w Twoim imieniu przez użytkownika :creator."
} }

View File

@ -0,0 +1,286 @@
/* Base */
body, body *:not(html):not(style):not(br):not(tr):not(code) {
font-family: Avenir, Helvetica, sans-serif;
box-sizing: border-box;
}
body {
background-color: #f5f8fa;
color: #74787e;
height: 100%;
hyphens: auto;
line-height: 1.4;
margin: 0;
-moz-hyphens: auto;
-ms-word-break: break-all;
width: 100% !important;
-webkit-hyphens: auto;
-webkit-text-size-adjust: none;
word-break: break-all;
word-break: break-word;
}
p,
ul,
ol,
blockquote {
line-height: 1.4;
text-align: left;
}
a {
color: #3c5f97;
}
a img {
border: none;
}
/* Typography */
h1 {
color: #2F3133;
font-size: 19px;
font-weight: bold;
margin-top: 0;
text-align: left;
}
h2 {
color: #2F3133;
font-size: 16px;
font-weight: bold;
margin-top: 0;
text-align: left;
}
h3 {
color: #2F3133;
font-size: 14px;
font-weight: bold;
margin-top: 0;
text-align: left;
}
p {
color: #74787e;
font-size: 16px;
line-height: 1.5em;
margin-top: 0;
text-align: left;
}
p.sub {
font-size: 12px;
}
img {
max-width: 100%;
}
/* Layout */
.wrapper {
background-color: #f5f8fa;
margin: 0;
padding: 0;
width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
}
.content {
margin: 0;
padding: 0;
width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
}
/* Header */
.header {
padding: 25px 0;
text-align: center;
}
.header a {
color: #3c5f97;
font-size: 19px;
font-weight: bold;
text-decoration: none;
text-shadow: 0 1px 0 #ffffff;
}
/* Body */
.body {
background-color: #ffffff;
border-bottom: 1px solid #edeff2;
border-top: 1px solid #edeff2;
margin: 0;
padding: 0;
width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
}
.inner-body {
background-color: #ffffff;
margin: 0 auto;
padding: 0;
width: 570px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 570px;
}
/* Subcopy */
.subcopy {
border-top: 1px solid #edeff2;
margin-top: 25px;
padding-top: 25px;
}
.subcopy p {
font-size: 12px;
}
/* Footer */
.footer {
margin: 0 auto;
padding: 0;
text-align: center;
width: 570px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 570px;
}
.footer p {
color: #aeaeae;
font-size: 12px;
text-align: center;
}
/* Tables */
.table table {
margin: 30px auto;
width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
}
.table th {
color: #74787e;
border-bottom: 1px solid #edeff2;
padding-bottom: 8px;
}
.table td {
color: #74787e;
font-size: 15px;
line-height: 18px;
padding: 10px 0;
}
.content-cell {
padding: 35px;
}
/* Buttons */
.action {
margin: 30px auto;
padding: 0;
text-align: center;
width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
}
.button {
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
color: #ffffff;
display: inline-block;
text-decoration: none;
-webkit-text-size-adjust: none;
}
.button-blue, .button-primary {
background-color: #3c5f97;
border-top: 10px solid #3c5f97;
border-right: 18px solid #3c5f97;
border-bottom: 10px solid #3c5f97;
border-left: 18px solid #3c5f97;
}
.button-green, .button-success {
background-color: #22c55e;
border-top: 10px solid #22c55e;
border-right: 18px solid #22c55e;
border-bottom: 10px solid #22c55e;
border-left: 18px solid #22c55e;
}
.button-red, .button-error {
background-color: #ef4444;
border-top: 10px solid #ef4444;
border-right: 18px solid #ef4444;
border-bottom: 10px solid #ef4444;
border-left: 18px solid #ef4444;
}
/* Panels */
.panel {
margin: 0 0 21px;
}
.panel-content {
background-color: #edeff2;
padding: 16px;
}
.panel-item {
padding: 0;
}
.panel-item p:last-of-type {
margin-bottom: 0;
padding-bottom: 0;
}
/* Promotions */
.promotion {
background-color: #FFFFFF;
border: 2px dashed #9BA2AB;
margin: 0;
margin-bottom: 25px;
margin-top: 25px;
padding: 24px;
width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
}
.promotion h1 {
text-align: center;
}
.promotion p {
font-size: 15px;
text-align: center;
}

View File

@ -7,6 +7,7 @@ use Toby\Infrastructure\Http\Controllers\GoogleController;
use Toby\Infrastructure\Http\Controllers\HolidayController; use Toby\Infrastructure\Http\Controllers\HolidayController;
use Toby\Infrastructure\Http\Controllers\LogoutController; use Toby\Infrastructure\Http\Controllers\LogoutController;
use Toby\Infrastructure\Http\Controllers\SelectYearPeriodController; use Toby\Infrastructure\Http\Controllers\SelectYearPeriodController;
use Toby\Infrastructure\Http\Controllers\TimesheetController;
use Toby\Infrastructure\Http\Controllers\UserController; use Toby\Infrastructure\Http\Controllers\UserController;
use Toby\Infrastructure\Http\Controllers\VacationCalendarController; use Toby\Infrastructure\Http\Controllers\VacationCalendarController;
use Toby\Infrastructure\Http\Controllers\VacationLimitController; use Toby\Infrastructure\Http\Controllers\VacationLimitController;
@ -24,8 +25,10 @@ Route::middleware("auth")->group(function (): void {
Route::get("/vacation-limits", [VacationLimitController::class, "edit"]) Route::get("/vacation-limits", [VacationLimitController::class, "edit"])
->name("vacation.limits"); ->name("vacation.limits");
Route::get("/vacation-calendar", [VacationCalendarController::class, "index"]) Route::get("/vacation-calendar/{month?}", [VacationCalendarController::class, "index"])
->name("vacation.calendar"); ->name("vacation.calendar");
Route::get("/timesheet/{month}", TimesheetController::class)
->name("timesheet");
Route::get("/vacation-limits", [VacationLimitController::class, "edit"])->name("vacation.limits"); Route::get("/vacation-limits", [VacationLimitController::class, "edit"])->name("vacation.limits");
Route::put("/vacation-limits", [VacationLimitController::class, "update"]); Route::put("/vacation-limits", [VacationLimitController::class, "update"]);

View File

@ -6,10 +6,14 @@ namespace Tests\Feature;
use Illuminate\Foundation\Testing\DatabaseMigrations; use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Event;
use Inertia\Testing\AssertableInertia as Assert; use Inertia\Testing\AssertableInertia as Assert;
use Tests\FeatureTestCase; use Tests\FeatureTestCase;
use Toby\Domain\Enums\VacationType; use Toby\Domain\Enums\VacationType;
use Toby\Domain\Events\VacationRequestAcceptedByAdministrative;
use Toby\Domain\Events\VacationRequestAcceptedByTechnical;
use Toby\Domain\Events\VacationRequestApproved;
use Toby\Domain\Events\VacationRequestRejected;
use Toby\Domain\PolishHolidaysRetriever; use Toby\Domain\PolishHolidaysRetriever;
use Toby\Domain\States\VacationRequest\Approved; use Toby\Domain\States\VacationRequest\Approved;
use Toby\Domain\States\VacationRequest\Rejected; use Toby\Domain\States\VacationRequest\Rejected;
@ -19,7 +23,6 @@ use Toby\Eloquent\Models\User;
use Toby\Eloquent\Models\VacationLimit; use Toby\Eloquent\Models\VacationLimit;
use Toby\Eloquent\Models\VacationRequest; use Toby\Eloquent\Models\VacationRequest;
use Toby\Eloquent\Models\YearPeriod; use Toby\Eloquent\Models\YearPeriod;
use Toby\Infrastructure\Jobs\SendVacationRequestDaysToGoogleCalendar;
class VacationRequestTest extends FeatureTestCase class VacationRequestTest extends FeatureTestCase
{ {
@ -31,8 +34,6 @@ class VacationRequestTest extends FeatureTestCase
{ {
parent::setUp(); parent::setUp();
Bus::fake();
$this->polishHolidaysRetriever = $this->app->make(PolishHolidaysRetriever::class); $this->polishHolidaysRetriever = $this->app->make(PolishHolidaysRetriever::class);
} }
@ -72,6 +73,7 @@ class VacationRequestTest extends FeatureTestCase
$this->actingAs($user) $this->actingAs($user)
->post("/vacation-requests", [ ->post("/vacation-requests", [
"user" => $user->id,
"type" => VacationType::Vacation->value, "type" => VacationType::Vacation->value,
"from" => Carbon::create($currentYearPeriod->year, 2, 7)->toDateString(), "from" => Carbon::create($currentYearPeriod->year, 2, 7)->toDateString(),
"to" => Carbon::create($currentYearPeriod->year, 2, 11)->toDateString(), "to" => Carbon::create($currentYearPeriod->year, 2, 11)->toDateString(),
@ -91,8 +93,87 @@ class VacationRequestTest extends FeatureTestCase
]); ]);
} }
public function testUserCanCreateVacationRequestOnEmployeeBehalf(): void
{
$creator = User::factory()->createQuietly();
$user = User::factory()->createQuietly();
$currentYearPeriod = YearPeriod::current();
VacationLimit::factory([
"days" => 20,
])
->for($user)
->for($currentYearPeriod)
->create();
$this->actingAs($creator)
->post("/vacation-requests", [
"user" => $user->id,
"type" => VacationType::Vacation->value,
"from" => Carbon::create($currentYearPeriod->year, 2, 7)->toDateString(),
"to" => Carbon::create($currentYearPeriod->year, 2, 11)->toDateString(),
"comment" => "Comment for the vacation request.",
])
->assertSessionHasNoErrors();
$this->assertDatabaseHas("vacation_requests", [
"user_id" => $user->id,
"creator_id" => $creator->id,
"year_period_id" => $currentYearPeriod->id,
"name" => "1/" . $currentYearPeriod->year,
"type" => VacationType::Vacation->value,
"state" => WaitingForTechnical::$name,
"from" => Carbon::create($currentYearPeriod->year, 2, 7)->toDateString(),
"to" => Carbon::create($currentYearPeriod->year, 2, 11)->toDateString(),
"comment" => "Comment for the vacation request.",
]);
}
public function testUserCanCreateVacationRequestOnEmployeeBehalfAndSkipAcceptanceFlow(): void
{
Event::fake(VacationRequestApproved::class);
$creator = User::factory()->createQuietly();
$user = User::factory()->createQuietly();
$currentYearPeriod = YearPeriod::current();
VacationLimit::factory([
"days" => 20,
])
->for($user)
->for($currentYearPeriod)
->create();
$this->actingAs($creator)
->post("/vacation-requests", [
"user" => $user->id,
"type" => VacationType::Vacation->value,
"from" => Carbon::create($currentYearPeriod->year, 2, 7)->toDateString(),
"to" => Carbon::create($currentYearPeriod->year, 2, 11)->toDateString(),
"comment" => "Comment for the vacation request.",
"flowSkipped" => true,
])
->assertSessionHasNoErrors();
$this->assertDatabaseHas("vacation_requests", [
"user_id" => $user->id,
"creator_id" => $creator->id,
"year_period_id" => $currentYearPeriod->id,
"name" => "1/" . $currentYearPeriod->year,
"type" => VacationType::Vacation->value,
"state" => Approved::$name,
"from" => Carbon::create($currentYearPeriod->year, 2, 7)->toDateString(),
"to" => Carbon::create($currentYearPeriod->year, 2, 11)->toDateString(),
"comment" => "Comment for the vacation request.",
]);
}
public function testTechnicalApproverCanApproveVacationRequest(): void public function testTechnicalApproverCanApproveVacationRequest(): void
{ {
Event::fake(VacationRequestAcceptedByTechnical::class);
$user = User::factory()->createQuietly(); $user = User::factory()->createQuietly();
$technicalApprover = User::factory()->createQuietly(); $technicalApprover = User::factory()->createQuietly();
$currentYearPeriod = YearPeriod::current(); $currentYearPeriod = YearPeriod::current();
@ -109,13 +190,13 @@ class VacationRequestTest extends FeatureTestCase
->post("/vacation-requests/{$vacationRequest->id}/accept-as-technical") ->post("/vacation-requests/{$vacationRequest->id}/accept-as-technical")
->assertSessionHasNoErrors(); ->assertSessionHasNoErrors();
$this->assertDatabaseHas("vacation_requests", [ Event::assertDispatched(VacationRequestAcceptedByTechnical::class);
"state" => WaitingForAdministrative::$name,
]);
} }
public function testAdministrativeApproverCanApproveVacationRequest(): void public function testAdministrativeApproverCanApproveVacationRequest(): void
{ {
Event::fake(VacationRequestAcceptedByAdministrative::class);
$user = User::factory()->createQuietly(); $user = User::factory()->createQuietly();
$administrativeApprover = User::factory()->createQuietly(); $administrativeApprover = User::factory()->createQuietly();
@ -132,15 +213,13 @@ class VacationRequestTest extends FeatureTestCase
->post("/vacation-requests/{$vacationRequest->id}/accept-as-administrative") ->post("/vacation-requests/{$vacationRequest->id}/accept-as-administrative")
->assertSessionHasNoErrors(); ->assertSessionHasNoErrors();
$this->assertDatabaseHas("vacation_requests", [ Event::assertDispatched(VacationRequestAcceptedByAdministrative::class);
"state" => Approved::$name,
]);
Bus::assertDispatched(SendVacationRequestDaysToGoogleCalendar::class);
} }
public function testTechnicalApproverCanRejectVacationRequest(): void public function testTechnicalApproverCanRejectVacationRequest(): void
{ {
Event::fake(VacationRequestRejected::class);
$user = User::factory()->createQuietly(); $user = User::factory()->createQuietly();
$technicalApprover = User::factory()->createQuietly(); $technicalApprover = User::factory()->createQuietly();
$currentYearPeriod = YearPeriod::current(); $currentYearPeriod = YearPeriod::current();
@ -164,6 +243,7 @@ class VacationRequestTest extends FeatureTestCase
->post("/vacation-requests/{$vacationRequest->id}/reject") ->post("/vacation-requests/{$vacationRequest->id}/reject")
->assertSessionHasNoErrors(); ->assertSessionHasNoErrors();
Event::assertDispatched(VacationRequestRejected::class);
$this->assertDatabaseHas("vacation_requests", [ $this->assertDatabaseHas("vacation_requests", [
"state" => Rejected::$name, "state" => Rejected::$name,
]); ]);
@ -183,6 +263,7 @@ class VacationRequestTest extends FeatureTestCase
$this->actingAs($user) $this->actingAs($user)
->post("/vacation-requests", [ ->post("/vacation-requests", [
"user" => $user->id,
"type" => VacationType::Vacation->value, "type" => VacationType::Vacation->value,
"from" => Carbon::create($currentYearPeriod->year, 2, 7)->toDateString(), "from" => Carbon::create($currentYearPeriod->year, 2, 7)->toDateString(),
"to" => Carbon::create($currentYearPeriod->year, 2, 11)->toDateString(), "to" => Carbon::create($currentYearPeriod->year, 2, 11)->toDateString(),
@ -207,6 +288,7 @@ class VacationRequestTest extends FeatureTestCase
$this->actingAs($user) $this->actingAs($user)
->post("/vacation-requests", [ ->post("/vacation-requests", [
"user" => $user->id,
"type" => VacationType::Vacation->value, "type" => VacationType::Vacation->value,
"from" => Carbon::create($currentYearPeriod->year, 2, 5)->toDateString(), "from" => Carbon::create($currentYearPeriod->year, 2, 5)->toDateString(),
"to" => Carbon::create($currentYearPeriod->year, 2, 6)->toDateString(), "to" => Carbon::create($currentYearPeriod->year, 2, 6)->toDateString(),
@ -238,6 +320,7 @@ class VacationRequestTest extends FeatureTestCase
$this->actingAs($user) $this->actingAs($user)
->post("/vacation-requests", [ ->post("/vacation-requests", [
"user" => $user->id,
"type" => VacationType::Vacation->value, "type" => VacationType::Vacation->value,
"from" => Carbon::create($currentYearPeriod->year, 4, 18)->toDateString(), "from" => Carbon::create($currentYearPeriod->year, 4, 18)->toDateString(),
"to" => Carbon::create($currentYearPeriod->year, 4, 18)->toDateString(), "to" => Carbon::create($currentYearPeriod->year, 4, 18)->toDateString(),
@ -273,6 +356,7 @@ class VacationRequestTest extends FeatureTestCase
$this->actingAs($user) $this->actingAs($user)
->post("/vacation-requests", [ ->post("/vacation-requests", [
"user" => $user->id,
"type" => VacationType::Vacation->value, "type" => VacationType::Vacation->value,
"from" => Carbon::create($currentYearPeriod->year, 2, 1)->toDateString(), "from" => Carbon::create($currentYearPeriod->year, 2, 1)->toDateString(),
"to" => Carbon::create($currentYearPeriod->year, 2, 4)->toDateString(), "to" => Carbon::create($currentYearPeriod->year, 2, 4)->toDateString(),
@ -280,8 +364,7 @@ class VacationRequestTest extends FeatureTestCase
]) ])
->assertSessionHasErrors([ ->assertSessionHasErrors([
"vacationRequest" => __("You have pending vacation request in this range."), "vacationRequest" => __("You have pending vacation request in this range."),
]) ]);
;
} }
public function testUserCannotCreateVacationRequestIfHeHasApprovedVacationRequestInThisRange(): void public function testUserCannotCreateVacationRequestIfHeHasApprovedVacationRequestInThisRange(): void
@ -309,6 +392,7 @@ class VacationRequestTest extends FeatureTestCase
$this->actingAs($user) $this->actingAs($user)
->post("/vacation-requests", [ ->post("/vacation-requests", [
"user" => $user->id,
"type" => VacationType::Vacation->value, "type" => VacationType::Vacation->value,
"from" => Carbon::create($currentYearPeriod->year, 2, 1)->toDateString(), "from" => Carbon::create($currentYearPeriod->year, 2, 1)->toDateString(),
"to" => Carbon::create($currentYearPeriod->year, 2, 4)->toDateString(), "to" => Carbon::create($currentYearPeriod->year, 2, 4)->toDateString(),
@ -325,6 +409,7 @@ class VacationRequestTest extends FeatureTestCase
$currentYearPeriod = YearPeriod::current(); $currentYearPeriod = YearPeriod::current();
$this->actingAs($user) $this->actingAs($user)
->post("/vacation-requests", [ ->post("/vacation-requests", [
"user" => $user->id,
"type" => VacationType::Vacation->value, "type" => VacationType::Vacation->value,
"from" => Carbon::create($currentYearPeriod->year, 2, 7)->toDateString(), "from" => Carbon::create($currentYearPeriod->year, 2, 7)->toDateString(),
"to" => Carbon::create($currentYearPeriod->year, 2, 6)->toDateString(), "to" => Carbon::create($currentYearPeriod->year, 2, 6)->toDateString(),
@ -342,6 +427,7 @@ class VacationRequestTest extends FeatureTestCase
$nextYearPeriod = $this->createYearPeriod(Carbon::now()->year + 1); $nextYearPeriod = $this->createYearPeriod(Carbon::now()->year + 1);
$this->actingAs($user) $this->actingAs($user)
->post("/vacation-requests", [ ->post("/vacation-requests", [
"user" => $user->id,
"type" => VacationType::Vacation->value, "type" => VacationType::Vacation->value,
"from" => Carbon::create($currentYearPeriod->year, 12, 27)->toDateString(), "from" => Carbon::create($currentYearPeriod->year, 12, 27)->toDateString(),
"to" => Carbon::create($nextYearPeriod->year, 1, 2)->toDateString(), "to" => Carbon::create($nextYearPeriod->year, 1, 2)->toDateString(),

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
use Tests\Traits\InteractsWithYearPeriods;
use Toby\Domain\Enums\Role;
use Toby\Domain\Enums\VacationType;
use Toby\Domain\Notifications\VacationRequestWaitsForTechApprovalNotification;
use Toby\Domain\States\VacationRequest\Created;
use Toby\Domain\VacationRequestStateManager;
use Toby\Eloquent\Models\User;
use Toby\Eloquent\Models\VacationRequest;
use Toby\Eloquent\Models\YearPeriod;
class VacationRequestNotificationTest extends TestCase
{
use DatabaseMigrations;
use InteractsWithYearPeriods;
protected VacationRequestStateManager $stateManager;
protected function setUp(): void
{
parent::setUp();
$this->stateManager = $this->app->make(VacationRequestStateManager::class);
$this->createCurrentYearPeriod();
}
public function testAfterChangingVacationRequestStateNotificationAreSentToUsers(): void
{
Notification::fake();
$user = User::factory([
"role" => Role::Employee,
])->createQuietly();
$technicalApprover = User::factory([
"role" => Role::TechnicalApprover,
])->createQuietly();
$administrativeApprover = User::factory([
"role" => Role::AdministrativeApprover,
])->createQuietly();
$currentYearPeriod = YearPeriod::current();
/** @var VacationRequest $vacationRequest */
$vacationRequest = VacationRequest::factory([
"type" => VacationType::Vacation->value,
"state" => Created::class,
"from" => Carbon::create($currentYearPeriod->year, 2, 1)->toDateString(),
"to" => Carbon::create($currentYearPeriod->year, 2, 4)->toDateString(),
"comment" => "Comment for the vacation request.",
])
->for($user)
->for($currentYearPeriod)
->create();
$this->stateManager->waitForTechnical($vacationRequest);
Notification::assertSentTo($technicalApprover, VacationRequestWaitsForTechApprovalNotification::class);
Notification::assertNotSentTo([$user, $administrativeApprover], VacationRequestWaitsForTechApprovalNotification::class);
}
}

View File

@ -6,6 +6,7 @@ namespace Tests\Unit;
use Illuminate\Foundation\Testing\DatabaseMigrations; use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase; use Tests\TestCase;
use Tests\Traits\InteractsWithYearPeriods; use Tests\Traits\InteractsWithYearPeriods;
use Toby\Domain\Enums\VacationType; use Toby\Domain\Enums\VacationType;
@ -28,6 +29,8 @@ class VacationRequestStatesTest extends TestCase
{ {
parent::setUp(); parent::setUp();
Notification::fake();
$this->stateManager = $this->app->make(VacationRequestStateManager::class); $this->stateManager = $this->app->make(VacationRequestStateManager::class);
$this->createCurrentYearPeriod(); $this->createCurrentYearPeriod();