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
19 changes: 5 additions & 14 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,11 +38,9 @@ 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),
]);
}

Expand All @@ -55,8 +50,6 @@ public function publish(Quiz $quiz, PublishQuizRankingAction $publishQuizRanking
abort(404);
}

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

$publishQuizRankingAction->execute($quiz);

return redirect()
Expand All @@ -70,8 +63,6 @@ public function unpublish(Quiz $quiz, UnpublishQuizRankingAction $unpublishQuizR
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,
];
}
}
12 changes: 6 additions & 6 deletions database/seeders/AdminSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ 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"),
kamilpiech97 marked this conversation as resolved.
Show resolved Hide resolved
"remember_token" => Str::random(10),
Expand All @@ -31,8 +31,8 @@ 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"),
"remember_token" => Str::random(10),
Expand All @@ -44,8 +44,8 @@ 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"),
"remember_token" => Str::random(10),
Expand Down
12 changes: 10 additions & 2 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 @@ -18,11 +19,9 @@ public function run(): void
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 +31,14 @@ 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();
}
}
}
}
23 changes: 20 additions & 3 deletions database/seeders/UserSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,32 @@

namespace Database\Seeders;

use App\Models\School;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;

use function fake;

class UserSeeder extends Seeder
{
public function run(): void
{
User::factory()->superAdmin()->create();
User::factory()->admin()->create();
User::factory()->create();
for ($i = 1; $i < 10; $i++) {
dawidrudnik marked this conversation as resolved.
Show resolved Hide resolved
$user = User::firstOrCreate(
["email" => "user{$i}@example.com"],
[
"name" => fake()->name,
"surname" => fake()->name,
"email_verified_at" => Carbon::now(),
"password" => Hash::make("user{$i}@example.com"),
kamilpiech97 marked this conversation as resolved.
Show resolved Hide resolved
"remember_token" => Str::random(10),
"school_id" => School::all()->random()->id,
kamilpiech97 marked this conversation as resolved.
Show resolved Hide resolved
],
);
$user->syncRoles("user");
}
}
}
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" 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[]
}
8 changes: 4 additions & 4 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@
Route::post("/quizzes/{quiz}/lock", [QuizController::class, "lock"])->name("admin.quizzes.lock");
Route::post("/quizzes/{quiz}/unlock", [QuizController::class, "unlock"])->can("unlock,quiz")->name("admin.quizzes.unlock");

Route::get("/quizzes/{quiz}/ranking", [RankingController::class, "index"])->name("admin.quizzes.ranking");
Route::post("/quizzes/{quiz}/ranking/publish", [RankingController::class, "publish"])->name("admin.quizzes.ranking.publish");
Route::post("/quizzes/{quiz}/ranking/unpublish", [RankingController::class, "unpublish"])->name("admin.quizzes.ranking.unpublish");
Route::get("/quizzes/{quiz}/ranking", [RankingController::class, "index"])->can("viewAdminRanking,quiz")->name("admin.quizzes.ranking");
Route::post("/quizzes/{quiz}/ranking/publish", [RankingController::class, "publish"])->can("publish,quiz")->name("admin.quizzes.ranking.publish");
Route::post("/quizzes/{quiz}/ranking/unpublish", [RankingController::class, "unpublish"])->can("publish,quiz")->name("admin.quizzes.ranking.unpublish");

Route::post("/quizzes/{quiz}/questions", [QuizQuestionController::class, "store"])->can("create," . Question::class . ",quiz")->name("admin.questions.store");
Route::patch("/questions/{question}", [QuizQuestionController::class, "update"])->can("update,question")->name("admin.questions.update");
Expand Down Expand Up @@ -90,7 +90,7 @@
Route::middleware(["auth", "verified"])->group(function (): void {
Route::post("/quizzes/{quiz}/assign", [QuizController::class, "assign"])->can("assign,quiz")->name("quizzes.assign");
Route::post("/quizzes/{quiz}/start", [QuizController::class, "createSubmission"])->middleware(EnsureQuizIsNotAlreadyStarted::class)->can("submit,quiz")->name("quizzes.start");
Route::get("/quizzes/{quiz}/ranking", [RankingController::class, "indexUser"])->name("quizzes.ranking");
Route::get("/quizzes/{quiz}/ranking", [RankingController::class, "indexUser"])->can("viewUserRanking,quiz")->name("quizzes.ranking");
Route::get("/submissions/{quizSubmission}/", [QuizSubmissionController::class, "show"])->can("view,quizSubmission")->name("submissions.show");
Route::post("/submissions/{quizSubmission}/close", [QuizSubmissionController::class, "close"])->can("close,quizSubmission")->name("submissions.close");
Route::get("/submissions/{quizSubmission}/result", [QuizSubmissionController::class, "result"])->can("result,quizSubmission")->name("submissions.result");
Expand Down
Loading