Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#36 - Ranking #99

Merged
merged 10 commits into from
Oct 28, 2024
27 changes: 5 additions & 22 deletions app/Http/Controllers/RankingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use App\Actions\PublishQuizRankingAction;
use App\Actions\UnpublishQuizRankingAction;
use App\Http\Resources\QuizResource;
use App\Http\Resources\RankingResource;
use App\Models\Quiz;
use App\Models\QuizSubmission;
Expand All @@ -17,18 +18,14 @@ class RankingController extends Controller
{
public function index(Quiz $quiz): Response
{
$this->authorize("viewAdminRanking", $quiz);

$submissions = QuizSubmission::query()
->where("quiz_id", $quiz->id)
->with("user.school")
->get();

$rankings = $submissions->map(fn($submission): RankingResource => new RankingResource($submission));

return Inertia::render("Admin/Ranking", [
"quiz" => $quiz,
"rankings" => $rankings,
"quiz" => QuizResource::make($quiz),
"rankings" => RankingResource::collection($submissions),
]);
}

Expand All @@ -41,22 +38,14 @@ public function indexUser(Quiz $quiz): Response
->with("user.school")
->get();

$rankings = $submissions->map(fn($submission): RankingResource => new RankingResource($submission));

return Inertia::render("User/Ranking", [
"quiz" => $quiz,
"rankings" => $rankings,
"quiz" => QuizResource::make($quiz),
"rankings" => RankingResource::collection($submissions),
]);
}

public function publish(Quiz $quiz, PublishQuizRankingAction $publishQuizRankingAction): RedirectResponse
{
if (!$quiz->exists) {
abort(404);
}

$this->authorize("publish", $quiz);

$publishQuizRankingAction->execute($quiz);

return redirect()
Expand All @@ -66,12 +55,6 @@ public function publish(Quiz $quiz, PublishQuizRankingAction $publishQuizRanking

public function unpublish(Quiz $quiz, UnpublishQuizRankingAction $unpublishQuizRankingAction): RedirectResponse
{
if (!$quiz->exists) {
abort(404);
}

$this->authorize("publish", $quiz);

$unpublishQuizRankingAction->execute($quiz);

return redirect()
Expand Down
1 change: 1 addition & 0 deletions app/Http/Resources/QuizResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public function toArray($request): array
"canBeUnlocked" => $this->canBeUnlocked,
"questions" => QuestionResource::collection($this->questions),
"isUserAssigned" => $this->isUserAssigned($request->user()),
"isRankingPublished" => $this->isRankingPublished,
];
}
}
2 changes: 1 addition & 1 deletion app/Policies/QuizPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,6 @@ public function viewUserRanking(User $user, Quiz $quiz): Response

public function publish(User $user, Quiz $quiz): bool
{
return $quiz->isLocked && $user->hasRole("admin|super_admin");
return $quiz->isLocked && $user->hasRole("admin|super_admin") && $quiz->quizSubmissions->isNotEmpty();
}
}
18 changes: 9 additions & 9 deletions database/seeders/AdminSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ public function run(): void
$superAdmin = User::firstOrCreate(
["email" => "superadmin@example.com"],
[
"name" => "Super Admin Name",
"surname" => "Super Admin Surname",
"name" => "Example",
"surname" => "Super Admin",
"email_verified_at" => Carbon::now(),
"password" => Hash::make("superadmin@example.com"),
"password" => Hash::make("password"),
"remember_token" => Str::random(10),
"school_id" => School::factory()->create()->id,
],
Expand All @@ -31,10 +31,10 @@ public function run(): void
$admin = User::firstOrCreate(
["email" => "admin@example.com"],
[
"name" => "Admin Name",
"surname" => "Admin Surname",
"name" => "Example",
"surname" => "Admin",
"email_verified_at" => Carbon::now(),
"password" => Hash::make("admin@example.com"),
"password" => Hash::make("password"),
"remember_token" => Str::random(10),
"school_id" => School::factory()->create()->id,
],
Expand All @@ -44,10 +44,10 @@ public function run(): void
$user = User::firstOrCreate(
["email" => "user@example.com"],
[
"name" => "User Name",
"surname" => "User Surname",
"name" => "Example",
"surname" => "User",
"email_verified_at" => Carbon::now(),
"password" => Hash::make("user@example.com"),
"password" => Hash::make("password"),
"remember_token" => Str::random(10),
"school_id" => School::factory()->create()->id,
],
Expand Down
15 changes: 12 additions & 3 deletions database/seeders/DatabaseSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Models\Answer;
use App\Models\Question;
use App\Models\Quiz;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Database\Seeder;

Expand All @@ -17,12 +18,9 @@ public function run(): void
$this->call([
RoleSeeder::class,
AdminSeeder::class,
UserSeeder::class,
UserQuizSeeder::class,
]);

Quiz::factory()->locked()->count(5)->create(["scheduled_at" => Carbon::now()->addMonth()]);
Quiz::factory()->locked()->create(["name" => "6 Minutes", "scheduled_at" => Carbon::now(), "duration" => 6]);

$quiz = Quiz::factory()->locked()->create(["name" => "Test Quiz", "scheduled_at" => Carbon::now(), "duration" => 2]);
$questions = Question::factory()->count(10)->create(["quiz_id" => $quiz->id]);
Expand All @@ -32,5 +30,16 @@ public function run(): void
$question->correct_answer_id = $answers[0]->id;
$question->save();
}

foreach (User::query()->role("user")->get() as $user) {
$submission = $quiz->createSubmission($user);

foreach ($submission->answerRecords as $answer) {
$answer->answer()->associate($answer->question->answers->random());
$answer->save();
}
}

User::factory()->count(10)->create();
}
}
18 changes: 0 additions & 18 deletions database/seeders/UserSeeder.php

This file was deleted.

7 changes: 7 additions & 0 deletions resources/js/Helpers/GroupBy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function groupBy<T>(key: keyof T, data: T[]): T[][] {
const grouped = data.reduce((prev, current) => (
{...prev, [current[key]]: [...(prev[current[key]] || []), current]}
), {})

return Object.values(grouped).reverse()
}
76 changes: 43 additions & 33 deletions resources/js/Pages/Admin/Ranking.vue
Original file line number Diff line number Diff line change
@@ -1,44 +1,54 @@
<script setup lang="ts">
import {computed, ref} from 'vue'
import type { QuizRankingProps} from '@/Types/Ranking'
import {computed} from 'vue'
import type {Ranking} from '@/Types/Ranking'
import Divider from '@/components/Common/Divider.vue'
import FormButton from '@/components/Common/FormButton.vue'
import {groupBy} from '@/Helpers/GroupBy'
import type {Quiz} from '@/Types/Quiz'

const props = defineProps<QuizRankingProps>()
const props = defineProps<{
quiz: Quiz
rankings: Ranking[]
}>()

const quiz = ref(props.quiz)
const rankings = ref(props.rankings)
const sorted = computed(() => [...props.rankings].toSorted((a, b) =>
`${a.user.name} ${a.user.surname}`.localeCompare(`${b.user.name} ${b.user.surname}`)))

const grouped = computed(() => groupBy('points', sorted.value))

const sortedRankings = computed(() => {
return [...rankings.value].sort((a, b) => b.points - a.points)
})
</script>

<template>
<div>
<h1>Ranking Quizu: {{ quiz.name }}</h1>
<div class="flex gap-4">
<FormButton method="post" :href="`/admin/quizzes/${quiz.id}/ranking/publish`" small preserve-scroll>Publikuj</FormButton>
<FormButton method="post" :href="`/admin/quizzes/${quiz.id}/ranking/unpublish`" small preserve-scroll>Wycofaj publikacje</FormButton>
<div class="w-full p-2 md:max-w-8xl">
<Divider>
<h1 class="font-bold text-xl text-primary text-center p-4 whitespace-nowrap">{{ quiz.name }} - Ranking</h1>
</Divider>

<div class="w-full flex justify-between text-sm font-semibold text-gray-900 border shadow bg-white rounded-md px-4 py-2 gap-x-1">
<div class="flex-1 sm:flex-none sm:w-full sm:max-w-56">Imię</div>
<div class="flex-1">Nazwisko</div>
<div class="flex-1">Szkoła</div>
<div class="flex-none w-full max-w-16">Punkty</div>
</div>

<div v-for="(place, index) in grouped" :key="index" class="mt-4">
<div class="mt-2 bg-white border shadow rounded-md">
<h1 class="text-white bg-primary font-semibold border-b rounded-t-md p-2 text-sm text-left">
Miejsce {{ index + 1 }}
</h1>

<div v-for="ranking in place" :key="ranking.user.id" class="w-full flex justify-between text-sm text-gray-900 p-4 gap-x-1">
<div class="flex-1 sm:flex-none sm:w-full sm:max-w-56">{{ ranking.user.name }}</div>
<div class="flex-1">{{ ranking.user.surname }}</div>
<div class="flex-1">{{ ranking.user.school.name }}</div>
<div class="flex-none w-full max-w-16 text-center">{{ ranking.points }}</div>
</div>
</div>
</div>

<div class="flex gap-4 p-4 pl-0">
<FormButton :disabled="quiz.isRankingPublished || rankings.length == 0" method="post" :href="`/admin/quizzes/${quiz.id}/ranking/publish`" small preserve-scroll>Publikuj</FormButton>
<FormButton :disabled="!quiz.isRankingPublished" method="post" :href="`/admin/quizzes/${quiz.id}/ranking/unpublish`" small preserve-scroll>Wycofaj publikacje</FormButton>
</div>
<table>
<thead>
<tr>
<th>ID Użytkownika</th>
<th>Imię</th>
<th>Nazwisko</th>
<th>Szkoła</th>
<th>Punkty</th>
</tr>
</thead>
<tbody>
<tr v-for="(ranking) in sortedRankings" :key="ranking.user.id">
<td>{{ ranking.user.id }}</td>
<td>{{ ranking.user.name }}</td>
<td>{{ ranking.user.surname }}</td>
<td>{{ ranking.user.school.name }}</td>
<td>{{ ranking.points }}</td>
</tr>
</tbody>
</table>
</div>
</template>
63 changes: 37 additions & 26 deletions resources/js/Pages/User/Ranking.vue
Original file line number Diff line number Diff line change
@@ -1,35 +1,46 @@
<script setup lang="ts">
import {computed, ref} from 'vue'
import type { QuizRankingProps} from '@/Types/Ranking'
import {computed} from 'vue'
import type {Ranking} from '@/Types/Ranking'
import Divider from '@/components/Common/Divider.vue'
import {groupBy} from '@/Helpers/GroupBy'
import type {Quiz} from '@/Types/Quiz'
import type {PageProps} from '@/Types/PageProps'

const props = defineProps<QuizRankingProps>()
const props = defineProps<{
quiz: Quiz
rankings: Ranking[]
} & PageProps>()

const quiz = ref(props.quiz)
const rankings = ref(props.rankings)
const grouped = computed(() => groupBy('points', props.rankings))

const sortedRankings = computed(() => {
return [...rankings.value].sort((a, b) => b.points - a.points)
})
</script>

<template>
<div>
<h1>Ranking Quizu: {{ quiz.name }}</h1>
<table>
<thead>
<tr>
<th>ID Użytkownika</th>
<th>Szkoła</th>
<th>Punkty</th>
</tr>
</thead>
<tbody>
<tr v-for="(ranking) in sortedRankings" :key="ranking.user.id">
<td>{{ ranking.user.id }}</td>
<td>{{ ranking.user.school.name }}</td>
<td>{{ ranking.points }}</td>
</tr>
</tbody>
</table>
<div class="w-full p-2 md:max-w-8xl">
<Divider>
<h1 class="font-bold text-xl text-primary text-center p-4 whitespace-nowrap">{{ quiz.name }} - Ranking</h1>
</Divider>

<div class="w-full flex justify-between text-sm font-semibold text-gray-900 border shadow bg-white rounded-md px-4 py-2 gap-x-2">
<div class="sm:flex-none sm:w-full sm:max-w-56">Imię</div>
<div class="flex-1">Nazwisko</div>
<div class="flex-1">Szkoła</div>
<div class="sm:flex-none sm:w-full max-w-16">Punkty</div>
</div>

<div v-for="(place, index) in grouped" :key="index" class="mt-4">
<div class="mt-2 bg-white border shadow rounded-md">
<h1 class="text-white font-semibold border-b bg-primary rounded-t-md p-2 text-sm text-left">
Miejsce {{ index + 1 }}
</h1>

<div v-for="ranking in place" :key="ranking.user.id" class="w-full flex justify-between text-sm text-gray-900 p-4 gap-x-2">
<div class="sm:flex-none sm:w-full sm:max-w-56" :class="[ranking.user.id === user.id ? 'text-black' : 'text-gray-500']">{{ ranking.user.id === user.id ? user.name : ranking.user.name ?? '---' }}</div>
<div class="flex-1" :class="[ranking.user.id === user.id ? 'text-black' : 'text-gray-500']">{{ ranking.user.id === user.id ? user.surname : ranking.user.surname ?? '---' }}</div>
<div class="flex-1" :class="[ranking.user.id === user.id ? 'text-black' : 'text-gray-500']">{{ ranking.user.school.name }}</div>
<div class="sm:flex-none sm:w-full max-w-16 text-center" :class="[ranking.user.id === user.id ? 'text-black' : 'text-gray-500']">{{ ranking.points }}</div>
</div>
</div>
</div>
</div>
</template>
1 change: 1 addition & 0 deletions resources/js/Types/Quiz.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ export interface Quiz {
duration?: number
state:'published' | 'locked' | 'unlocked'
isUserAssigned: boolean
isRankingPublished: boolean
questions: Question[]
}
12 changes: 2 additions & 10 deletions resources/js/Types/Ranking.d.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,7 @@
import { type Quiz } from '@/Types/Quiz'
import {type School} from '@/Types/School'
import type {User} from '@/Types/User'

export interface Ranking {
id: number
name: string | null
surname: string | null
school: School
user: User
points: number
}

export interface QuizRankingProps {
quiz: Quiz
rankings: Ranking[]
}
6 changes: 3 additions & 3 deletions resources/js/components/Common/Banner.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@
import { XMarkIcon } from '@heroicons/vue/20/solid'

defineProps<{text:string}>()
defineEmits<{ click: [] }>()
const emit = defineEmits<{ click: [] }>()
</script>

<template>
<div class="flex absolute top-0 w-full items-center gap-x-6 bg-primary px-6 py-2.5 sm:px-3.5 sm:before:flex-1">
<div class="text-white z-50 flex sticky top-0 w-full items-center gap-x-6 bg-primary px-6 py-2.5 sm:px-3.5 sm:before:flex-1">
<b class="text-white">
{{ text }}
</b>
<div class="flex flex-1 justify-end">
<button type="button" class="-m-3 p-3 focus-visible:outline-offset-[-4px]">
<XMarkIcon class="size-5 text-white" aria-hidden="true" @click="$emit('click')" />
<XMarkIcon class="size-5 text-white" aria-hidden="true" @click="emit('click')" />
</button>
</div>
</div>
Expand Down
Loading
Loading