This commit is contained in:
Adrian Hopek 2022-02-25 10:29:45 +01:00
parent 5cb46d2fc4
commit 5ded4008c7
8 changed files with 373 additions and 291 deletions

View File

@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Toby\Infrastructure\Http\Controllers;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Inertia\Response;
use Toby\Domain\Enums\VacationRequestState;
use Toby\Domain\Enums\VacationType;
use Toby\Domain\VacationTypeConfigRetriever;
use Toby\Eloquent\Helpers\YearPeriodRetriever;
use Toby\Eloquent\Models\Holiday;
use Toby\Eloquent\Models\Vacation;
use Toby\Eloquent\Models\VacationRequest;
use Toby\Infrastructure\Http\Resources\AbsenceResource;
use Toby\Infrastructure\Http\Resources\HolidayResource;
use Toby\Infrastructure\Http\Resources\VacationRequestResource;
class DashboardController extends Controller
{
public function __construct(
protected VacationTypeConfigRetriever $configRetriever,
protected YearPeriodRetriever $yearPeriodRetriever
) {
}
public function __invoke(Request $request): Response
{
$absences = Vacation::query()
->with(["user", "vacationRequest"])
->whereDate("date", Carbon::now())
->whereRelation(
"vacationRequest",
fn(Builder $query) => $query->states(VacationRequestState::successStates())
)
->get();
$vacationRequests = VacationRequest::query()
->latest("updated_at")
->limit(3)
->get();
$holidays = Holiday::query()
->whereDate("date", ">=", Carbon::now())
->latest()
->limit(3)
->get();
$limit = $request->user()
->vacationLimits()
->where("year_period_id", $this->yearPeriodRetriever->current()->id)
->first()
->days ?? 0;
$used = $request->user()
->vacations()
->whereRelation(
"vacationRequest",
fn(Builder $query) => $query
->whereIn("type", $this->getLimitableVacationTypes())
->noStates(VacationRequestState::successStates()),
)
->count();
$pending = $request->user()
->vacations()
->whereRelation(
"vacationRequest",
fn(Builder $query) => $query
->whereIn("type", $this->getLimitableVacationTypes())
->noStates(VacationRequestState::pendingStates()),
)
->count();
$other = $request->user()
->vacations()
->whereRelation(
"vacationRequest",
fn(Builder $query) => $query
->whereIn("type", $this->getNotLimitableVacationTypes())
->noStates(VacationRequestState::successStates()),
)
->count();
return inertia("Dashboard", [
"absences" => AbsenceResource::collection($absences),
"vacationRequests" => VacationRequestResource::collection($vacationRequests),
"holidays" => HolidayResource::collection($holidays),
"stats" => [
"limit" => $limit,
"remaining" => $limit - $used - $pending,
"used" => $used,
"pending" => $pending,
"other" => $other,
],
]);
}
protected function getLimitableVacationTypes(): Collection
{
$types = new Collection(VacationType::cases());
return $types->filter(fn(VacationType $type) => $this->configRetriever->hasLimit($type));
}
protected function getNotLimitableVacationTypes(): Collection
{
$types = new Collection(VacationType::cases());
return $types->filter(fn(VacationType $type) => !$this->configRetriever->hasLimit($type));
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Toby\Infrastructure\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class AbsenceResource extends JsonResource
{
public static $wrap = null;
public function toArray($request): array
{
return [
"id" => $this->id,
"user" => new UserResource($this->user),
"date" => $this->date->toDisplayString(),
"cause" => $this->vacationRequest->type,
];
}
}

4
package-lock.json generated
View File

@ -1,5 +1,5 @@
{
"name": "application",
"name": "toby",
"lockfileVersion": 2,
"requires": true,
"packages": {
@ -16,7 +16,7 @@
"@vue/compiler-sfc": "^3.2.26",
"autoprefixer": "^10.4.2",
"axios": "^0.25.0",
"echarts": "^5.2.2",
"echarts": "^5.3.0",
"flatpickr": "^4.6.9",
"laravel-mix": "^6.0.6",
"lodash": "^4.17.21",

View File

@ -23,7 +23,7 @@
"@vue/compiler-sfc": "^3.2.26",
"autoprefixer": "^10.4.2",
"axios": "^0.25.0",
"echarts": "^5.2.2",
"echarts": "^5.3.0",
"flatpickr": "^4.6.9",
"laravel-mix": "^6.0.6",
"lodash": "^4.17.21",

View File

@ -1,17 +1,9 @@
<template>
<InertiaHead title="Strona główna" />
<div class="grid grid-cols-1 gap-4 items-start lg:grid-cols-3 lg:gap-8">
<!-- Left column -->
<div class="grid grid-cols-1 gap-4 lg:col-span-2">
<!-- Welcome panel -->
<section aria-labelledby="profile-overview-title">
<div class="rounded-lg bg-white overflow-hidden shadow">
<h2
id="profile-overview-title"
class="sr-only"
>
Profile Overview
</h2>
<section>
<div class=" bg-white overflow-hidden shadow">
<div class="bg-white p-6">
<div class="sm:flex sm:items-center sm:justify-between">
<div class="sm:flex sm:space-x-5">
@ -24,7 +16,7 @@
</div>
<div class="mt-4 text-center sm:mt-0 sm:pt-1 sm:text-left">
<p class="text-sm font-medium text-gray-600">
Welcome back,
Cześć,
</p>
<p class="text-xl font-bold text-gray-900 sm:text-2xl">
{{ user.name }}
@ -34,136 +26,86 @@
</p>
</div>
</div>
<div class="mt-5 flex justify-center sm:mt-0">
<InertiaLink
href="#"
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"
>
View profile
</InertiaLink>
</div>
</div>
</div>
<div
class="border-t border-gray-200 bg-gray-50 grid grid-cols-1 divide-y divide-gray-200 sm:grid-cols-3 sm:divide-y-0 sm:divide-x"
>
<div
v-for="stat in stats"
:key="stat.label"
class="px-6 py-5 text-sm font-medium text-center"
>
<span class="text-gray-900">{{ stat.value }}</span>
{{ ' ' }}
<span class="text-gray-600">{{ stat.label }}</span>
</div>
</div>
</div>
</section>
<!-- Actions panel -->
<section aria-labelledby="quick-links-title">
<div
class="rounded-lg bg-gray-200 overflow-hidden shadow divide-y divide-gray-200 sm:divide-y-0 sm:grid sm:grid-cols-2 sm:gap-px"
>
<h2
id="quick-links-title"
class="sr-only"
>
Quick links
</h2>
<div
v-for="(action, actionIdx) in actions"
:key="action.name"
:class="[actionIdx === 0 ? 'rounded-tl-lg rounded-tr-lg sm:rounded-tr-none' : '', actionIdx === 1 ? 'sm:rounded-tr-lg' : '', actionIdx === actions.length - 2 ? 'sm:rounded-bl-lg' : '', actionIdx === actions.length - 1 ? 'rounded-bl-lg rounded-br-lg sm:rounded-bl-none' : '', 'relative group bg-white p-6 focus-within:ring-2 focus-within:ring-inset focus-within:ring-cyan-500']"
>
<div>
<span
:class="[action.iconBackground, action.iconForeground, 'rounded-lg inline-flex p-3 ring-4 ring-white']"
>
<component
:is="action.icon"
class="h-6 w-6"
aria-hidden="true"
/>
</span>
<section>
<div class="grid grid-cols-2 gap-4">
<div class="bg-white shadow-md p-4">
<VacationChart :stats="stats" />
</div>
<div class="h-full">
<div class="grid grid-cols-2 gap-4 h-full">
<div class="px-4 py-5 bg-white shadow-md sm:p-6">
<dt class="text-sm font-medium text-gray-500 truncate">
Limit urlopów
</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">
{{ stats.limit }}
</dd>
</div>
<div class="px-4 py-5 bg-white shadow-md sm:p-6">
<dt class="text-sm font-medium text-gray-500 truncate">
Dni do wykorzystania
</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">
{{ stats.remaining }}
</dd>
</div>
<div class="px-4 py-5 bg-white shadow-md sm:p-6">
<dt class="text-sm font-medium text-gray-500 truncate">
Dni wykorzystane
</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">
{{ stats.used }}
</dd>
</div>
<div class="px-4 py-5 bg-white shadow-md sm:p-6">
<dt class="text-sm font-medium text-gray-500 truncate">
Inne urlopy
</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">
{{ stats.other }}
</dd>
</div>
</div>
<div class="mt-8">
<h3 class="text-lg font-medium">
<InertiaLink
:href="action.href"
class="focus:outline-none"
>
<!-- Extend touch target to entire panel -->
<span
class="absolute inset-0"
aria-hidden="true"
/>
{{ action.name }}
</InertiaLink>
</h3>
<p class="mt-2 text-sm text-gray-500">
Doloribus dolores nostrum quia qui natus officia quod et dolorem. Sit
repellendus qui ut at blanditiis et quo et molestiae.
</p>
</div>
<span
class="pointer-events-none absolute top-6 right-6 text-gray-300 group-hover:text-gray-400"
aria-hidden="true"
>
<svg
class="h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M20 4h1a1 1 0 00-1-1v1zm-1 12a1 1 0 102 0h-2zM8 3a1 1 0 000 2V3zM3.293 19.293a1 1 0 101.414 1.414l-1.414-1.414zM19 4v12h2V4h-2zm1-1H8v2h12V3zm-.707.293l-16 16 1.414 1.414 16-16-1.414-1.414z"
/>
</svg>
</span>
</div>
</div>
</section>
</div>
<!-- Right column -->
<div class="grid grid-cols-1 gap-4">
<!-- Announcements -->
<section aria-labelledby="announcements-title">
<div class="rounded-lg bg-white overflow-hidden shadow">
<div class="p-6">
<h2
id="announcements-title"
class="text-base font-medium text-gray-900"
>
Announcements
<section>
<div class="bg-white shadow-md">
<div class="p-4 sm:px-6">
<h2 class="text-lg leading-6 font-medium text-gray-900">
Twoje wnioski
</h2>
</div>
<div class="border-t border-gray-200 pb-5 px-4 sm:px-6">
<div class="flow-root mt-6">
<ul
role="list"
class="-my-5 divide-y divide-gray-200"
>
<ul class="-my-5 divide-y divide-gray-200">
<li
v-for="announcement in announcements"
:key="announcement.id"
v-for="request in vacationRequests.data"
:key="request.id"
class="py-5"
>
<div class="relative focus-within:ring-2 focus-within:ring-cyan-500">
<h3 class="text-sm font-semibold text-gray-800">
<h3 class="text-sm font-semibold text-blumilk-600 hover:text-blumilk-500">
<InertiaLink
:href="announcement.href"
:href="`/vacation-requests/${request.id}`"
class="hover:underline focus:outline-none"
>
<!-- Extend touch target to entire panel -->
<span
class="absolute inset-0"
aria-hidden="true"
/>
{{ announcement.title }}
<span class="absolute inset-0" />
Wniosek o {{ request.type.toLowerCase() }}
[{{ request.name }}]
</InertiaLink>
</h3>
<p class="mt-1 text-sm text-gray-600 line-clamp-2">
{{ announcement.preview }}
<p class="mt-1 text-sm text-gray-600">
{{ request.from }} - {{ request.to }}
</p>
<p class="mt-2 text-sm text-gray-600">
<Status :status="request.state" />
</p>
</div>
</li>
@ -171,71 +113,79 @@
</div>
<div class="mt-6">
<InertiaLink
href="#"
href="/vacation-requests"
class="w-full flex justify-center items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
View all
Zobacz wszystkie
</InertiaLink>
</div>
</div>
</div>
</section>
<!-- Recent Hires -->
<section aria-labelledby="recent-hires-title">
<div class="rounded-lg bg-white overflow-hidden shadow">
<div class="p-6">
<h2
id="recent-hires-title"
class="text-base font-medium text-gray-900"
>
Recent Hires
<section>
<div class="bg-white shadow-md">
<div class="p-4 sm:px-6">
<h2 class="text-lg leading-6 font-medium text-gray-900">
Dzisiejsze nieobecności
</h2>
<div class="flow-root mt-6">
<ul
role="list"
class="-my-5 divide-y divide-gray-200"
</div>
<div class="border-t border-gray-200 px-4 sm:px-6">
<ul class="divide-y divide-gray-200">
<li
v-for="absence in absences.data"
:key="absence.user.id"
class="py-4 flex"
>
<li
v-for="person in recentHires"
:key="person.handle"
class="py-4"
<img
class="h-10 w-10 rounded-full"
:src="absence.user.avatar"
>
<div class="flex items-center space-x-4">
<div class="flex-shrink-0">
<img
class="h-8 w-8 rounded-full"
:src="person.imageUrl"
alt=""
>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">
{{ person.name }}
</p>
<p class="text-sm text-gray-500 truncate">
{{ '@' + person.handle }}
</p>
</div>
<div>
<InertiaLink
:href="person.href"
class="inline-flex items-center shadow-sm px-2.5 py-0.5 border border-gray-300 text-sm leading-5 font-medium rounded-full text-gray-700 bg-white hover:bg-gray-50"
>
View
</InertiaLink>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-900">
{{ absence.user.name }}
</p>
<p class="text-sm text-gray-500">
{{ absence.user.email }}
</p>
</div>
</li>
</ul>
</div>
</div>
</section>
<section>
<div class="bg-white shadow-md">
<div>
<div class="p-4 sm:px-6">
<h2 class="text-lg leading-6 font-medium text-gray-900">
Najbliższe dni wolne
</h2>
</div>
<div class="border-t border-gray-200 px-4 pb-5 sm:px-6">
<ul class="divide-y divide-gray-200">
<li
v-for="holiday in holidays.data"
:key="holiday.id.id"
class="py-4 flex"
>
<div>
<p class="text-sm font-medium text-gray-900">
{{ holiday.name }}
</p>
<p class="text-sm text-gray-500">
{{ holiday.displayDate }}
</p>
</div>
</li>
</ul>
</div>
<div class="mt-6">
<InertiaLink
href="#"
class="w-full flex justify-center items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
View all
</InertiaLink>
<div>
<InertiaLink
href="/holidays"
class="w-full flex justify-center items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Zobacz wszystkie
</InertiaLink>
</div>
</div>
</div>
</div>
@ -245,129 +195,41 @@
</template>
<script>
import {
AcademicCapIcon,
BadgeCheckIcon,
CashIcon,
ClockIcon,
ReceiptRefundIcon,
UsersIcon,
} from '@heroicons/vue/outline'
import {computed} from 'vue'
import {usePage} from '@inertiajs/inertia-vue3'
import Status from '@/Shared/Status'
import VacationChart from '@/Shared/VacationChart'
export default {
name: 'DashboardPage',
components: {Status, VacationChart},
props: {
absences: {
type: Object,
default: null,
},
vacationRequests: {
type: Object,
default: null,
},
holidays: {
type: Object,
default: null,
},
stats: {
type: Object,
default: () => ({
used: 0,
pending: 0,
other: 0,
}),
},
},
setup() {
const user = computed(() => usePage().props.value.auth.user)
const stats = [
{label: 'Vacation days left', value: 12},
{label: 'Sick days left', value: 4},
{label: 'Personal days left', value: 2},
]
const actions = [
{
icon: ClockIcon,
name: 'Request time off',
href: '#',
iconForeground: 'text-teal-700',
iconBackground: 'bg-teal-50',
},
{
icon: BadgeCheckIcon,
name: 'Benefits',
href: '#',
iconForeground: 'text-purple-700',
iconBackground: 'bg-purple-50',
},
{
icon: UsersIcon,
name: 'Schedule a one-on-one',
href: '#',
iconForeground: 'text-sky-700',
iconBackground: 'bg-sky-50',
},
{
icon: CashIcon,
name: 'Payroll',
href: '#',
iconForeground: 'text-yellow-700',
iconBackground: 'bg-yellow-50',
},
{
icon: ReceiptRefundIcon,
name: 'Submit an expense',
href: '#',
iconForeground: 'text-rose-700',
iconBackground: 'bg-rose-50',
},
{
icon: AcademicCapIcon,
name: 'Training',
href: '#',
iconForeground: 'text-indigo-700',
iconBackground: 'bg-indigo-50',
},
]
const recentHires = [
{
name: 'Leonard Krasner',
handle: 'leonardkrasner',
imageUrl:
'https://images.unsplash.com/photo-1519345182560-3f2917c472ef?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
href: '#',
},
{
name: 'Floyd Miles',
handle: 'floydmiles',
imageUrl:
'https://images.unsplash.com/photo-1463453091185-61582044d556?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
href: '#',
},
{
name: 'Emily Selman',
handle: 'emilyselman',
imageUrl:
'https://images.unsplash.com/photo-1502685104226-ee32379fefbe?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
href: '#',
},
{
name: 'Kristin Watson',
handle: 'kristinwatson',
imageUrl:
'https://images.unsplash.com/photo-1500917293891-ef795e70e1f6?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
href: '#',
},
]
const announcements = [
{
id: 1,
title: 'Office closed on July 2nd',
href: '#',
preview:
'Cum qui rem deleniti. Suscipit in dolor veritatis sequi aut. Vero ut earum quis deleniti. Ut a sunt eum cum ut repudiandae possimus. Nihil ex tempora neque cum consectetur dolores.',
},
{
id: 2,
title: 'New password policy',
href: '#',
preview:
'Alias inventore ut autem optio voluptas et repellendus. Facere totam quaerat quam quo laudantium cumque eaque excepturi vel. Accusamus maxime ipsam reprehenderit rerum id repellendus rerum. Culpa cum vel natus. Est sit autem mollitia.',
},
{
id: 3,
title: 'Office closed on July 2nd',
href: '#',
preview:
'Tenetur libero voluptatem rerum occaecati qui est molestiae exercitationem. Voluptate quisquam iure assumenda consequatur ex et recusandae. Alias consectetur voluptatibus. Accusamus a ab dicta et. Consequatur quis dignissimos voluptatem nisi.',
},
]
return {
user,
stats,
actions,
recentHires,
announcements,
}
},
}

View File

@ -2,7 +2,7 @@
<div class="min-h-full">
<MainMenu />
<main class="lg:ml-64 flex flex-col flex-1 py-8">
<div>
<div class="px-4">
<slot />
</div>
</main>

View File

@ -0,0 +1,81 @@
<template>
<v-chart
style="height: 600px;"
:option="option"
/>
</template>
<script>
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { PieChart } from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
LegendComponent,
} from 'echarts/components'
import VChart from 'vue-echarts'
import {computed} from 'vue'
use([
CanvasRenderer,
PieChart,
TitleComponent,
TooltipComponent,
LegendComponent,
])
export default {
name: 'VacationChart',
components: {
VChart,
},
props: {
stats: {
type: Object,
default: () => ({
used: 0,
pending: 0,
remaining: 0,
}),
},
},
setup(props) {
const option = computed(() => ({
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b} : {c} ({d}%)',
},
color: [
'#2C466F',
'#AABDDD',
'#527ABA',
],
legend: {
orient: 'vertical',
left: 'left',
data: ['Wykorzystane', 'Rozpatrywane', 'Pozostałe'],
},
series: [
{
name: 'Urlop wypoczynkowy',
type: 'pie',
label: {
show: true,
textStyle: {
fontSize: 16,
},
},
data: [
{ value: props.stats.used, name: 'Wykorzystane' },
{ value: props.stats.pending, name: 'Rozpatrywane' },
{ value: props.stats.remaining, name: 'Pozostałe' },
],
},
],
}))
return { option }
},
}
</script>

View File

@ -3,6 +3,7 @@
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
use Toby\Infrastructure\Http\Controllers\DashboardController;
use Toby\Infrastructure\Http\Controllers\GoogleController;
use Toby\Infrastructure\Http\Controllers\HolidayController;
use Toby\Infrastructure\Http\Controllers\LogoutController;
@ -14,7 +15,7 @@ use Toby\Infrastructure\Http\Controllers\VacationLimitController;
use Toby\Infrastructure\Http\Controllers\VacationRequestController;
Route::middleware("auth")->group(function (): void {
Route::get("/", fn() => inertia("Dashboard"))
Route::get("/", DashboardController::class)
->name("dashboard");
Route::post("/logout", LogoutController::class);