๐ ์จ๋ผ์ธ ์๊ฒฉ์ฆ ์ํ ์์ ํ๋ซํผ - ์ค์๊ฐ ํ์ด๋จธ & ์๋ ์ฑ์ ์์คํ
online-certification-exam-platform
๋ฌธ์ ์ค๋ช
ํ๋ก์ ํธ ๊ฐ์
์ค์ ์๊ฒฉ์ฆ ์ํ์ฒ๋ผ ์ ํ ์๊ฐ ๋ด์ ๋ฌธ์ ๋ฅผ ํ๊ณ ์๋์ผ๋ก ์ฑ์ ๋๋ ์จ๋ผ์ธ ์ํ ์์ ํ๋ซํผ์ ๋ง๋ญ๋๋ค. ๊ด๋ฆฌ์๋ ์ํ์ ์์ฑํ๊ณ ๋ฌธ์ ๋ฅผ ๋ฑ๋กํ๋ฉฐ, ์์์๋ ์ค์๊ฐ ํ์ด๋จธ๋ฅผ ๋ณด๋ฉด์ ์ํ์ ์น๋ฅด๊ณ ์ฆ์ ๊ฒฐ๊ณผ๋ฅผ ํ์ธํ ์ ์์ต๋๋ค.
ํต์ฌ ๊ธฐ๋ฅ
ํ์ ๊ธฐ๋ฅ
- โ ์ํ ๊ด๋ฆฌ ์์คํ : ๊ด๋ฆฌ์๊ฐ ์ํ ์์ฑ (์ ๋ชฉ, ์ค๋ช , ์ ํ์๊ฐ, ํฉ๊ฒฉ์ ์, ๊ณต๊ฐ/๋น๊ณต๊ฐ ์ค์ )
- โ ๋ฌธ์ ์ํ: ๊ฐ๊ด์/์ฃผ๊ด์ ๋ฌธ์ ๋ฑ๋ก (์ ๋ต, ๋ฐฐ์ , ์นดํ ๊ณ ๋ฆฌ ์ค์ )
- โ ์์์ ๋์๋ณด๋: ๊ณต๊ฐ๋ ์ํ ๋ชฉ๋ก ์กฐํ ๋ฐ ์์ ๊ฐ๋ฅ ์ฌ๋ถ ํ์ธ
- โ ์ค์๊ฐ ํ์ด๋จธ: JavaScript๋ก ๊ตฌํ๋ ์นด์ดํธ๋ค์ด ํ์ด๋จธ (์๊ฐ ์ด๊ณผ ์ ์๋ ์ ์ถ)
- โ ์ํ ์์ ํ์ด์ง: ๋ฌธ์ ๋ค๋น๊ฒ์ด์ , ๋ต์ ์์ ์ ์ฅ, ์งํ๋ฅ ํ์
- โ ์๋ ์ฑ์ : ์ ์ถ ์ฆ์ ๊ฐ๊ด์ ์๋ ์ฑ์ ๋ฐ ์ ์ ๊ณ์ฐ
- โ ๊ฒฐ๊ณผ ๋ฆฌํฌํธ: ์ด์ , ํฉ๊ฒฉ/๋ถํฉ๊ฒฉ, ๋ฌธ์ ๋ณ ์ ์ค๋ต ํ์, ์ ๋ต๋ฅ ํต๊ณ
- โ ์์ ๊ธฐ๋ก: ์ฌ์ฉ์๋ณ ์ํ ์์ ํ์คํ ๋ฆฌ ๋ฐ ์ฌ์์ ์ ํ ์ค์
- โ ์ธ์ฆ ์์คํ : Laravel Breeze ๊ธฐ๋ฐ ํ์๊ฐ์ /๋ก๊ทธ์ธ (์ญํ : ๊ด๋ฆฌ์/์์์)
๋ณด๋์ค ๊ธฐ๋ฅ
- ๐ ์ํ ์นดํ ๊ณ ๋ฆฌ: ํ๋ก๊ทธ๋๋ฐ, ๋์์ธ, ๋ง์ผํ ๋ฑ ์นดํ ๊ณ ๋ฆฌ๋ณ ์ํ ๋ถ๋ฅ
- ๐ ๋์ด๋ ํํฐ: ์ด๊ธ/์ค๊ธ/๊ณ ๊ธ ๋์ด๋๋ณ ์ํ ๊ฒ์
- ๐ ๋ฌธ์ ๋๋ค ์ถ์ : ๋ฌธ์ ์ํ์์ ๋ฌด์์๋ก N๊ฐ ์ถ์ (๋งค๋ฒ ๋ค๋ฅธ ์ํ์ง)
- ๐ ํฉ๊ฒฉ์ฆ PDF ๋ฐ๊ธ: ํฉ๊ฒฉ ์ PDF ์ธ์ฆ์ ์๋ ์์ฑ (Laravel DomPDF)
- ๐ ์ํ ํต๊ณ ๋์๋ณด๋: ์์์ ์, ํ๊ท ์ ์, ํฉ๊ฒฉ๋ฅ ์ฐจํธ (Chart.js)
- ๐ ์ด๋ฉ์ผ ์๋ฆผ: ์ํ ๊ฒฐ๊ณผ ๋ฐ ํฉ๊ฒฉ์ฆ ์ด๋ฉ์ผ ๋ฐ์ก (Laravel Mail)
- ๐ ๋ถ์ ํ์ ๋ฐฉ์ง: ํญ ์ ํ ๊ฐ์ง ๋ฐ ๊ฒฝ๊ณ (JavaScript Visibility API)
- ๐ ๋คํฌ๋ชจ๋: Tailwind CSS ๋คํฌ๋ชจ๋ ํ ๊ธ
๊ธฐ์ ์คํ
- ๋ฐฑ์๋: Laravel 13.x + Blade ํ ํ๋ฆฟ
- ํ๋ก ํธ์๋: Tailwind CSS 4.x + Alpine.js (๊ฒฝ๋ ์ธํฐ๋์ )
- ๋ฐ์ดํฐ๋ฒ ์ด์ค: SQLite (๊ฐ๋ฐ) / MySQL (ํ๋ก๋์ )
- ์ธ์ฆ: Laravel Breeze (Blade ์คํ)
- ์ถ๊ฐ ๋ผ์ด๋ธ๋ฌ๋ฆฌ: Chart.js (ํต๊ณ), DomPDF (PDF ์์ฑ)
์ฃผ์ ํ๋ฉด ๊ตฌ์ฑ
- ํ: ๊ณต๊ฐ ์ํ ๋ชฉ๋ก (์นด๋ ๋ ์ด์์, ๋์ด๋/์นดํ ๊ณ ๋ฆฌ ๋ฐฐ์ง)
- ์ํ ์์ธ: ์ํ ์ ๋ณด, ์์ ์กฐ๊ฑด, ์์ ๋ฒํผ
- ์ํ ์์: ๋ฌธ์ ๋ฆฌ์คํธ, ์ค์๊ฐ ํ์ด๋จธ, ๋ต์ ์ ํ UI
- ๊ฒฐ๊ณผ ํ์ด์ง: ์ ์, ํฉ๊ฒฉ/๋ถํฉ๊ฒฉ, ๋ฌธ์ ๋ณ ์ ์ค๋ต ํด์ค
- ๋ง์ดํ์ด์ง: ์์ ๊ธฐ๋ก, ํฉ๊ฒฉ์ฆ ๋ค์ด๋ก๋
- ๊ด๋ฆฌ์ ํจ๋: ์ํ/๋ฌธ์ CRUD, ์์์ ํต๊ณ
์ ์ฝ ์กฐ๊ฑด
- โข Laravel 13.x + Blade ํ ํ๋ฆฟ ์ฌ์ฉ (React/Vue ๊ธ์ง)
- โข Tailwind CSS 4.x๋ก ๋ฐ์ํ UI ๊ตฌํ
- โข Alpine.js๋ก ๊ฒฝ๋ ์ธํฐ๋์ ์ฒ๋ฆฌ (ํ์ด๋จธ, ๋ชจ๋ฌ, ํ ๊ธ)
- โข Laravel Breeze Blade ์คํ์ผ๋ก ์ธ์ฆ ๊ตฌํ
- โข SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฌ์ฉ (๋ง์ด๊ทธ๋ ์ด์ ํ์)
- โข Eloquent ORM์ผ๋ก ๊ด๊ณํ ๋ฐ์ดํฐ ์ค๊ณ (์ํ-๋ฌธ์ -์์๊ธฐ๋ก 1:N ๊ด๊ณ)
- โข Laravel Policy๋ก ๊ถํ ์ ์ด (๊ด๋ฆฌ์๋ง ์ํ ์์ฑ/์์ ๊ฐ๋ฅ)
- โข JavaScript Fetch API๋ก AJAX ๋ต์ ์์ ์ ์ฅ
- โข Laravel Validation์ผ๋ก ์ ๋ ฅ๊ฐ ๊ฒ์ฆ
- โข Blade ์ปดํฌ๋ํธ๋ก ์ฌ์ฌ์ฉ ๊ฐ๋ฅํ UI ๋ชจ๋ํ
- โข ์ค์๊ฐ ํ์ด๋จธ๋ JavaScript setInterval๋ก ๊ตฌํ (์๋ฒ ํ์์คํฌํ ๊ธฐ์ค)
- โข ์ํ ์ ์ถ ์ CSRF ํ ํฐ ๊ฒ์ฆ ํ์
ํ๋กฌํํธ ๋ ์ํผ
์๋ ํ๋กฌํํธ๋ฅผ ์์๋๋ก AI์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
mkdir online-certification-exam-platform && cd online-certification-exam-platform ์ด ํด๋์์ Laravel 13.x ํ๋ก์ ํธ๋ฅผ ์์ํฉ๋๋ค. 1. Laravel 13.x ํ๋ก์ ํธ ์์ฑ: ```bash composer create-project laravel/laravel:^13.0 . ``` 2. Laravel Breeze (Blade ์คํ) ์ค์น ๋ฐ ์ธ์ฆ ์ค์บํด๋ฉ ์์ฑ: ```bash composer require laravel/breeze --dev php artisan breeze:install blade php artisan migrate npm install && npm run dev ``` 3. Tailwind CSS 4.x ์ค์ ํ์ธ (Breeze๊ฐ ์๋ ์ค์ ): - `tailwind.config.js` ํ์ผ์ด ์์ฑ๋์๋์ง ํ์ธ - `resources/css/app.css`์ Tailwind ๋๋ ํฐ๋ธ๊ฐ ํฌํจ๋์๋์ง ํ์ธ 4. ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ค์ (SQLite): - `.env` ํ์ผ์์ `DB_CONNECTION=sqlite` ์ค์ - `touch database/database.sqlite` ๋ช ๋ น์ผ๋ก SQLite ํ์ผ ์์ฑ 5. ๊ธฐ๋ณธ ๋ผ์ฐํธ ํ์ธ: - `routes/web.php`์์ Breeze๊ฐ ์์ฑํ ์ธ์ฆ ๋ผ์ฐํธ ํ์ธ - `php artisan route:list` ์คํํ์ฌ ๋ผ์ฐํธ ๋ชฉ๋ก ํ์ธ 6. ๊ฐ๋ฐ ์๋ฒ ์คํ: ```bash php artisan serve ``` ๋ธ๋ผ์ฐ์ ์์ `http://localhost:8000` ์ ์ํ์ฌ Breeze ๊ธฐ๋ณธ ๋ ์ด์์ ํ์ธ.
๊ธฐ๋ ๊ฒฐ๊ณผ: Laravel 13.x ํ๋ก์ ํธ ์์ฑ ์๋ฃ. Breeze Blade ์คํ ์ค์น๋ก ํ์๊ฐ์ /๋ก๊ทธ์ธ ๊ธฐ๋ฅ ์ฌ์ฉ ๊ฐ๋ฅ. Tailwind CSS 4.x ์ค์ ์๋ฃ. SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฐ๊ฒฐ ์๋ฃ. ๊ฐ๋ฐ ์๋ฒ ์คํ ์ Breeze ๊ธฐ๋ณธ ํ ํ๋ฉด ํ์.
๋ฐ์ดํฐ๋ฒ ์ด์ค ์คํค๋ง ์ค๊ณ ๋ฐ ๋ง์ด๊ทธ๋ ์ด์
์์ฑ:
1. **User ๋ชจ๋ธ ํ์ฅ** (๊ด๋ฆฌ์/์์์ ์ญํ ๊ตฌ๋ถ):
```bash
php artisan make:migration add_role_to_users_table --table=users
```
๋ง์ด๊ทธ๋ ์ด์
ํ์ผ์์:
```php
$table->enum('role', ['admin', 'examinee'])->default('examinee');
```
2. **Exam ๋ชจ๋ธ** (์ํ ์ ๋ณด):
```bash
php artisan make:model Exam -m
```
๋ง์ด๊ทธ๋ ์ด์
:
```php
$table->id();
$table->string('title'); // ์ํ ์ ๋ชฉ
$table->text('description')->nullable(); // ์ํ ์ค๋ช
$table->integer('duration_minutes'); // ์ ํ ์๊ฐ (๋ถ)
$table->integer('passing_score'); // ํฉ๊ฒฉ ์ ์
$table->enum('status', ['draft', 'published'])->default('draft'); // ๊ณต๊ฐ ์ฌ๋ถ
$table->string('category')->nullable(); // ์นดํ
๊ณ ๋ฆฌ (ํ๋ก๊ทธ๋๋ฐ, ๋์์ธ ๋ฑ)
$table->enum('difficulty', ['beginner', 'intermediate', 'advanced'])->default('beginner'); // ๋์ด๋
$table->boolean('allow_retake')->default(true); // ์ฌ์์ ํ์ฉ ์ฌ๋ถ
$table->timestamps();
```
3. **Question ๋ชจ๋ธ** (๋ฌธ์ ):
```bash
php artisan make:model Question -m
```
๋ง์ด๊ทธ๋ ์ด์
:
```php
$table->id();
$table->foreignId('exam_id')->constrained()->onDelete('cascade');
$table->text('question_text'); // ๋ฌธ์ ๋ด์ฉ
$table->enum('question_type', ['multiple_choice', 'short_answer'])->default('multiple_choice');
$table->json('options')->nullable(); // ๊ฐ๊ด์ ์ ํ์ง (JSON ๋ฐฐ์ด)
$table->string('correct_answer'); // ์ ๋ต
$table->integer('points')->default(1); // ๋ฐฐ์
$table->integer('order')->default(0); // ๋ฌธ์ ์์
$table->timestamps();
```
4. **ExamAttempt ๋ชจ๋ธ** (์์ ๊ธฐ๋ก):
```bash
php artisan make:model ExamAttempt -m
```
๋ง์ด๊ทธ๋ ์ด์
:
```php
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('exam_id')->constrained()->onDelete('cascade');
$table->timestamp('started_at')->nullable(); // ์ํ ์์ ์๊ฐ
$table->timestamp('submitted_at')->nullable(); // ์ ์ถ ์๊ฐ
$table->json('answers')->nullable(); // ์ฌ์ฉ์ ๋ต์ (JSON)
$table->integer('score')->nullable(); // ์ด์
$table->boolean('passed')->default(false); // ํฉ๊ฒฉ ์ฌ๋ถ
$table->timestamps();
```
5. ๋ง์ด๊ทธ๋ ์ด์
์คํ:
```bash
php artisan migrate
```
6. ๋ชจ๋ธ ๊ด๊ณ ์ค์ :
- `app/Models/Exam.php`์ `questions()`, `attempts()` ๊ด๊ณ ์ถ๊ฐ
- `app/Models/Question.php`์ `exam()` ๊ด๊ณ ์ถ๊ฐ
- `app/Models/ExamAttempt.php`์ `user()`, `exam()` ๊ด๊ณ ์ถ๊ฐ
- `app/Models/User.php`์ `examAttempts()` ๊ด๊ณ ์ถ๊ฐ
๊ธฐ๋ ๊ฒฐ๊ณผ: ๋ฐ์ดํฐ๋ฒ ์ด์ค ํ ์ด๋ธ ์์ฑ ์๋ฃ: users (role ์ปฌ๋ผ ์ถ๊ฐ), exams, questions, exam_attempts. Eloquent ๋ชจ๋ธ ๊ฐ ๊ด๊ณ ์ค์ ์๋ฃ (1:N, N:1). ๋ง์ด๊ทธ๋ ์ด์ ์ฑ๊ณต์ ์ผ๋ก ์คํ๋จ.
์๋(Seeder)๋ก ์ํ ๋ฐ์ดํฐ ์์ฑ:
1. **DatabaseSeeder ์์ฑ**:
```bash
php artisan make:seeder ExamSeeder
```
`database/seeders/ExamSeeder.php`:
```php
use App\Models\Exam;
use App\Models\Question;
use App\Models\User;
public function run()
{
// ๊ด๋ฆฌ์ ๊ณ์ ์์ฑ
User::create([
'name' => 'Admin User',
'email' => 'admin@example.com',
'password' => bcrypt('password'),
'role' => 'admin',
]);
// ์์์ ๊ณ์ ์์ฑ
User::create([
'name' => 'Test Examinee',
'email' => 'examinee@example.com',
'password' => bcrypt('password'),
'role' => 'examinee',
]);
// ์ํ ์ํ 1: Laravel ๊ธฐ์ด
$exam1 = Exam::create([
'title' => 'Laravel ๊ธฐ์ด ์๊ฒฉ์ฆ ์ํ',
'description' => 'Laravel ํ๋ ์์ํฌ์ ๊ธฐ๋ณธ ๊ฐ๋
์ ํ
์คํธํฉ๋๋ค.',
'duration_minutes' => 30,
'passing_score' => 70,
'status' => 'published',
'category' => 'ํ๋ก๊ทธ๋๋ฐ',
'difficulty' => 'beginner',
'allow_retake' => true,
]);
Question::create([
'exam_id' => $exam1->id,
'question_text' => 'Laravel์ ORM ์ด๋ฆ์ ๋ฌด์์ธ๊ฐ์?',
'question_type' => 'multiple_choice',
'options' => json_encode(['Doctrine', 'Eloquent', 'Propel', 'ActiveRecord']),
'correct_answer' => 'Eloquent',
'points' => 10,
'order' => 1,
]);
Question::create([
'exam_id' => $exam1->id,
'question_text' => 'Blade ํ
ํ๋ฆฟ ์์ง์ ํ์ฅ์๋?',
'question_type' => 'multiple_choice',
'options' => json_encode(['.tpl', '.blade.php', '.php', '.html']),
'correct_answer' => '.blade.php',
'points' => 10,
'order' => 2,
]);
// ์ํ ์ํ 2: JavaScript ์ค๊ธ
$exam2 = Exam::create([
'title' => 'JavaScript ES6+ ์๊ฒฉ์ฆ',
'description' => '์ต์ JavaScript ๋ฌธ๋ฒ๊ณผ ๊ธฐ๋ฅ์ ํ๊ฐํฉ๋๋ค.',
'duration_minutes' => 45,
'passing_score' => 80,
'status' => 'published',
'category' => 'ํ๋ก๊ทธ๋๋ฐ',
'difficulty' => 'intermediate',
'allow_retake' => false,
]);
Question::create([
'exam_id' => $exam2->id,
'question_text' => 'Promise์ ์ธ ๊ฐ์ง ์ํ๊ฐ ์๋ ๊ฒ์?',
'question_type' => 'multiple_choice',
'options' => json_encode(['pending', 'fulfilled', 'rejected', 'completed']),
'correct_answer' => 'completed',
'points' => 15,
'order' => 1,
]);
}
```
2. `database/seeders/DatabaseSeeder.php`์์ ExamSeeder ํธ์ถ:
```php
public function run()
{
$this->call(ExamSeeder::class);
}
```
3. ์๋ ์คํ:
```bash
php artisan db:seed
```
4. ๋ฐ์ดํฐ ํ์ธ:
- Tinker๋ก ํ์ธ: `php artisan tinker` โ `Exam::with('questions')->get()`
- ๋๋ DB ๋ธ๋ผ์ฐ์ ๋๊ตฌ๋ก SQLite ํ์ผ ์ด๊ธฐ
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ํ ๋ฐ์ดํฐ ์์ฑ ์๋ฃ: ๊ด๋ฆฌ์ ๊ณ์ 1๊ฐ, ์์์ ๊ณ์ 1๊ฐ, ์ํ 2๊ฐ (๊ฐ๊ฐ ๋ฌธ์ 2~3๊ฐ ํฌํจ). ์๋ ์คํ ์ฑ๊ณต.
์์์์ฉ ํ ํ๋ฉด ๋ฐ ์ํ ๋ชฉ๋ก ํ์ด์ง ๊ตฌํ:
1. **ExamController ์์ฑ**:
```bash
php artisan make:controller ExamController
```
`app/Http/Controllers/ExamController.php`:
```php
use App\Models\Exam;
public function index(Request $request)
{
$query = Exam::where('status', 'published');
// ์นดํ
๊ณ ๋ฆฌ ํํฐ
if ($request->filled('category')) {
$query->where('category', $request->category);
}
// ๋์ด๋ ํํฐ
if ($request->filled('difficulty')) {
$query->where('difficulty', $request->difficulty);
}
$exams = $query->withCount('questions')->latest()->paginate(9);
$categories = Exam::distinct()->pluck('category');
return view('exams.index', compact('exams', 'categories'));
}
public function show(Exam $exam)
{
if ($exam->status !== 'published') {
abort(403, '๊ณต๊ฐ๋์ง ์์ ์ํ์
๋๋ค.');
}
$exam->load('questions');
$userAttempts = auth()->check()
? auth()->user()->examAttempts()->where('exam_id', $exam->id)->get()
: collect();
return view('exams.show', compact('exam', 'userAttempts'));
}
```
2. **๋ผ์ฐํธ ์ค์ ** (`routes/web.php`):
```php
use App\Http\Controllers\ExamController;
Route::get('/', [ExamController::class, 'index'])->name('home');
Route::get('/exams/{exam}', [ExamController::class, 'show'])->name('exams.show');
```
3. **Blade ๋ทฐ ์์ฑ**:
`resources/views/exams/index.blade.php`:
```blade
<x-app-layout>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<h1 class="text-3xl font-bold mb-8">๐ ์จ๋ผ์ธ ์๊ฒฉ์ฆ ์ํ</h1>
<!-- ํํฐ -->
<form method="GET" class="mb-8 flex gap-4">
<select name="category" class="rounded-md border-gray-300">
<option value="">์ ์ฒด ์นดํ
๊ณ ๋ฆฌ</option>
@foreach($categories as $cat)
<option value="{{ $cat }}" {{ request('category') == $cat ? 'selected' : '' }}>
{{ $cat }}
</option>
@endforeach
</select>
<select name="difficulty" class="rounded-md border-gray-300">
<option value="">์ ์ฒด ๋์ด๋</option>
<option value="beginner" {{ request('difficulty') == 'beginner' ? 'selected' : '' }}>์ด๊ธ</option>
<option value="intermediate" {{ request('difficulty') == 'intermediate' ? 'selected' : '' }}>์ค๊ธ</option>
<option value="advanced" {{ request('difficulty') == 'advanced' ? 'selected' : '' }}>๊ณ ๊ธ</option>
</select>
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md">ํํฐ ์ ์ฉ</button>
</form>
<!-- ์ํ ์นด๋ ๊ทธ๋ฆฌ๋ -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
@foreach($exams as $exam)
<div class="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition">
<div class="flex items-center justify-between mb-4">
<span class="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">{{ $exam->category }}</span>
<span class="text-xs text-gray-500">{{ $exam->difficulty }}</span>
</div>
<h2 class="text-xl font-bold mb-2">{{ $exam->title }}</h2>
<p class="text-gray-600 text-sm mb-4">{{ Str::limit($exam->description, 100) }}</p>
<div class="flex items-center justify-between text-sm text-gray-500 mb-4">
<span>โฑ๏ธ {{ $exam->duration_minutes }}๋ถ</span>
<span>๐ {{ $exam->questions_count }}๋ฌธ์ </span>
<span>โ
{{ $exam->passing_score }}์ </span>
</div>
<a href="{{ route('exams.show', $exam) }}" class="block bg-blue-600 text-white text-center py-2 rounded-md hover:bg-blue-700">
์ํ ๋ณด๊ธฐ
</a>
</div>
@endforeach
</div>
<div class="mt-8">
{{ $exams->links() }}
</div>
</div>
</div>
</x-app-layout>
```
`resources/views/exams/show.blade.php`:
```blade
<x-app-layout>
<div class="py-12">
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white rounded-lg shadow-md p-8">
<h1 class="text-3xl font-bold mb-4">{{ $exam->title }}</h1>
<p class="text-gray-600 mb-6">{{ $exam->description }}</p>
<div class="grid grid-cols-2 gap-4 mb-8">
<div class="border-l-4 border-blue-500 pl-4">
<p class="text-sm text-gray-500">์ ํ ์๊ฐ</p>
<p class="text-xl font-bold">{{ $exam->duration_minutes }}๋ถ</p>
</div>
<div class="border-l-4 border-green-500 pl-4">
<p class="text-sm text-gray-500">ํฉ๊ฒฉ ์ ์</p>
<p class="text-xl font-bold">{{ $exam->passing_score }}์ </p>
</div>
<div class="border-l-4 border-purple-500 pl-4">
<p class="text-sm text-gray-500">์ด ๋ฌธ์ ์</p>
<p class="text-xl font-bold">{{ $exam->questions->count() }}๋ฌธ์ </p>
</div>
<div class="border-l-4 border-orange-500 pl-4">
<p class="text-sm text-gray-500">์ฌ์์</p>
<p class="text-xl font-bold">{{ $exam->allow_retake ? '๊ฐ๋ฅ' : '๋ถ๊ฐ' }}</p>
</div>
</div>
@if($userAttempts->isNotEmpty())
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
<h3 class="font-bold mb-2">๐ ์์ ๊ธฐ๋ก</h3>
@foreach($userAttempts as $attempt)
<p class="text-sm">{{ $attempt->created_at->format('Y-m-d H:i') }} - ์ ์: {{ $attempt->score }}์ ({{ $attempt->passed ? 'โ
ํฉ๊ฒฉ' : 'โ ๋ถํฉ๊ฒฉ' }})</p>
@endforeach
</div>
@endif
@auth
<a href="{{ route('exams.start', $exam) }}" class="block bg-blue-600 text-white text-center py-3 rounded-md hover:bg-blue-700 font-bold">
๐ ์ํ ์์ํ๊ธฐ
</a>
@else
<p class="text-center text-gray-500">๋ก๊ทธ์ธ ํ ์ํ์ ์์ํ ์ ์์ต๋๋ค.</p>
<a href="{{ route('login') }}" class="block bg-gray-600 text-white text-center py-3 rounded-md hover:bg-gray-700 mt-4">
๋ก๊ทธ์ธ
</a>
@endauth
</div>
</div>
</div>
</x-app-layout>
```
๊ธฐ๋ ๊ฒฐ๊ณผ: ํ ํ๋ฉด์์ ๊ณต๊ฐ๋ ์ํ ๋ชฉ๋ก์ ์นด๋ ํํ๋ก ํ์. ์นดํ ๊ณ ๋ฆฌ/๋์ด๋ ํํฐ ์๋. ์ํ ์์ธ ํ์ด์ง์์ ์ํ ์ ๋ณด, ์์ ๊ธฐ๋ก ํ์. '์ํ ์์ํ๊ธฐ' ๋ฒํผ ํ์ (๋ก๊ทธ์ธ ํ์).
์ํ ์์ ํ์ด์ง ๊ตฌํ (์ค์๊ฐ ํ์ด๋จธ ํฌํจ):
1. **ExamController์ ์์ ๊ด๋ จ ๋ฉ์๋ ์ถ๊ฐ**:
```php
public function start(Exam $exam)
{
if ($exam->status !== 'published') {
abort(403);
}
// ์ฌ์์ ๋ถ๊ฐ ์ํ์ธ ๊ฒฝ์ฐ ๊ธฐ์กด ์์ ๊ธฐ๋ก ํ์ธ
if (!$exam->allow_retake) {
$existingAttempt = auth()->user()->examAttempts()
->where('exam_id', $exam->id)
->whereNotNull('submitted_at')
->first();
if ($existingAttempt) {
return redirect()->route('exams.result', $existingAttempt->id)
->with('error', '์ด๋ฏธ ์์ํ ์ํ์
๋๋ค.');
}
}
// ์ ์์ ๊ธฐ๋ก ์์ฑ
$attempt = auth()->user()->examAttempts()->create([
'exam_id' => $exam->id,
'started_at' => now(),
]);
return redirect()->route('exams.take', $attempt->id);
}
public function take(ExamAttempt $attempt)
{
if ($attempt->user_id !== auth()->id()) {
abort(403);
}
if ($attempt->submitted_at) {
return redirect()->route('exams.result', $attempt->id);
}
$exam = $attempt->exam->load('questions');
$endTime = $attempt->started_at->addMinutes($exam->duration_minutes);
return view('exams.take', compact('attempt', 'exam', 'endTime'));
}
public function saveAnswer(Request $request, ExamAttempt $attempt)
{
if ($attempt->user_id !== auth()->id() || $attempt->submitted_at) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$answers = $attempt->answers ?? [];
$answers[$request->question_id] = $request->answer;
$attempt->update(['answers' => $answers]);
return response()->json(['success' => true]);
}
public function submit(ExamAttempt $attempt)
{
if ($attempt->user_id !== auth()->id() || $attempt->submitted_at) {
abort(403);
}
$exam = $attempt->exam->load('questions');
$answers = $attempt->answers ?? [];
$score = 0;
// ์๋ ์ฑ์ (๊ฐ๊ด์๋ง)
foreach ($exam->questions as $question) {
$userAnswer = $answers[$question->id] ?? null;
if ($question->question_type === 'multiple_choice' && $userAnswer === $question->correct_answer) {
$score += $question->points;
}
}
$attempt->update([
'submitted_at' => now(),
'score' => $score,
'passed' => $score >= $exam->passing_score,
]);
return redirect()->route('exams.result', $attempt->id);
}
```
2. **๋ผ์ฐํธ ์ถ๊ฐ**:
```php
Route::middleware('auth')->group(function () {
Route::post('/exams/{exam}/start', [ExamController::class, 'start'])->name('exams.start');
Route::get('/attempts/{attempt}', [ExamController::class, 'take'])->name('exams.take');
Route::post('/attempts/{attempt}/answer', [ExamController::class, 'saveAnswer'])->name('exams.saveAnswer');
Route::post('/attempts/{attempt}/submit', [ExamController::class, 'submit'])->name('exams.submit');
});
```
3. **Blade ๋ทฐ ์์ฑ** (`resources/views/exams/take.blade.php`):
```blade
<x-app-layout>
<div class="py-12" x-data="examTimer(@js($endTime))">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
<!-- ํ์ด๋จธ & ์งํ๋ฅ -->
<div class="bg-white rounded-lg shadow-md p-4 mb-6 flex justify-between items-center">
<div>
<h2 class="text-xl font-bold">{{ $exam->title }}</h2>
<p class="text-sm text-gray-500">๋ฌธ์ <span x-text="currentQuestion">1</span> / {{ $exam->questions->count() }}</p>
</div>
<div class="text-right">
<p class="text-sm text-gray-500">๋จ์ ์๊ฐ</p>
<p class="text-2xl font-bold" :class="timeLeft < 300 ? 'text-red-600' : 'text-green-600'" x-text="formatTime(timeLeft)"></p>
</div>
</div>
<!-- ๋ฌธ์ -->
<div class="bg-white rounded-lg shadow-md p-8">
@foreach($exam->questions as $index => $question)
<div class="mb-8" x-show="currentQuestion === {{ $index + 1 }}">
<h3 class="text-lg font-bold mb-4">{{ $index + 1 }}. {{ $question->question_text }}</h3>
@if($question->question_type === 'multiple_choice')
<div class="space-y-3">
@foreach(json_decode($question->options) as $option)
<label class="flex items-center p-3 border rounded-md hover:bg-gray-50 cursor-pointer">
<input type="radio" name="question_{{ $question->id }}" value="{{ $option }}" class="mr-3" @change="saveAnswer({{ $question->id }}, $event.target.value)">
<span>{{ $option }}</span>
</label>
@endforeach
</div>
@else
<input type="text" class="w-full border-gray-300 rounded-md" placeholder="๋ต์ ์
๋ ฅํ์ธ์" @input="saveAnswer({{ $question->id }}, $event.target.value)">
@endif
</div>
@endforeach
<!-- ๋ค๋น๊ฒ์ด์
-->
<div class="flex justify-between mt-8">
<button @click="currentQuestion > 1 && currentQuestion--" class="bg-gray-300 px-4 py-2 rounded-md" :disabled="currentQuestion === 1">์ด์ </button>
<button @click="currentQuestion < {{ $exam->questions->count() }} && currentQuestion++" class="bg-blue-600 text-white px-4 py-2 rounded-md" :disabled="currentQuestion === {{ $exam->questions->count() }}">๋ค์</button>
</div>
<!-- ์ ์ถ ๋ฒํผ -->
<form method="POST" action="{{ route('exams.submit', $attempt) }}" class="mt-8">
@csrf
<button type="submit" class="w-full bg-green-600 text-white py-3 rounded-md font-bold hover:bg-green-700">
๐ฏ ์ํ ์ ์ถํ๊ธฐ
</button>
</form>
</div>
</div>
</div>
<script>
function examTimer(endTime) {
return {
currentQuestion: 1,
timeLeft: Math.floor((new Date(endTime) - new Date()) / 1000),
interval: null,
init() {
this.interval = setInterval(() => {
this.timeLeft--;
if (this.timeLeft <= 0) {
clearInterval(this.interval);
document.querySelector('form').submit(); // ์๋ ์ ์ถ
}
}, 1000);
},
formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
},
saveAnswer(questionId, answer) {
fetch(`/attempts/{{ $attempt->id }}/answer`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({ question_id: questionId, answer: answer })
});
}
}
}
</script>
</x-app-layout>
```
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ํ ์์ ํ์ด์ง ๊ตฌํ ์๋ฃ. ์ค์๊ฐ ์นด์ดํธ๋ค์ด ํ์ด๋จธ ์๋ (์๊ฐ ์ด๊ณผ ์ ์๋ ์ ์ถ). ๋ฌธ์ ๋ค๋น๊ฒ์ด์ (์ด์ /๋ค์ ๋ฒํผ). ๋ต์ ์ ํ ์ AJAX๋ก ์๋ ์ ์ฅ. ์ ์ถ ๋ฒํผ ํด๋ฆญ ์ ์ฑ์ ๋ฐ ๊ฒฐ๊ณผ ํ์ด์ง๋ก ์ด๋.
๊ฒฐ๊ณผ ํ์ด์ง ๋ฐ ๋ง์ดํ์ด์ง ๊ตฌํ:
1. **ExamController์ ๊ฒฐ๊ณผ ๋ฉ์๋ ์ถ๊ฐ**:
```php
public function result(ExamAttempt $attempt)
{
if ($attempt->user_id !== auth()->id()) {
abort(403);
}
if (!$attempt->submitted_at) {
return redirect()->route('exams.take', $attempt->id);
}
$exam = $attempt->exam->load('questions');
$answers = $attempt->answers ?? [];
// ๋ฌธ์ ๋ณ ์ ์ค๋ต ๋ถ์
$results = $exam->questions->map(function ($question) use ($answers) {
$userAnswer = $answers[$question->id] ?? null;
return [
'question' => $question,
'user_answer' => $userAnswer,
'is_correct' => $question->question_type === 'multiple_choice' && $userAnswer === $question->correct_answer,
];
});
return view('exams.result', compact('attempt', 'exam', 'results'));
}
```
2. **๋ผ์ฐํธ ์ถ๊ฐ**:
```php
Route::middleware('auth')->group(function () {
Route::get('/attempts/{attempt}/result', [ExamController::class, 'result'])->name('exams.result');
Route::get('/my-exams', [ExamController::class, 'myExams'])->name('exams.my');
});
```
3. **Blade ๋ทฐ** (`resources/views/exams/result.blade.php`):
```blade
<x-app-layout>
<div class="py-12">
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8">
<!-- ๊ฒฐ๊ณผ ์์ฝ -->
<div class="bg-white rounded-lg shadow-md p-8 mb-6 text-center">
@if($attempt->passed)
<div class="text-6xl mb-4">๐</div>
<h1 class="text-3xl font-bold text-green-600 mb-2">ํฉ๊ฒฉ์ ์ถํํฉ๋๋ค!</h1>
@else
<div class="text-6xl mb-4">๐ข</div>
<h1 class="text-3xl font-bold text-red-600 mb-2">์์ฝ๊ฒ๋ ๋ถํฉ๊ฒฉ์
๋๋ค</h1>
@endif
<div class="grid grid-cols-3 gap-4 mt-8">
<div class="border-l-4 border-blue-500 pl-4">
<p class="text-sm text-gray-500">์ทจ๋ ์ ์</p>
<p class="text-3xl font-bold">{{ $attempt->score }}์ </p>
</div>
<div class="border-l-4 border-green-500 pl-4">
<p class="text-sm text-gray-500">ํฉ๊ฒฉ ์ ์</p>
<p class="text-3xl font-bold">{{ $exam->passing_score }}์ </p>
</div>
<div class="border-l-4 border-purple-500 pl-4">
<p class="text-sm text-gray-500">์ ๋ต๋ฅ </p>
<p class="text-3xl font-bold">{{ round(($results->where('is_correct', true)->count() / $results->count()) * 100) }}%</p>
</div>
</div>
</div>
<!-- ๋ฌธ์ ๋ณ ์์ธ ๊ฒฐ๊ณผ -->
<div class="bg-white rounded-lg shadow-md p-8">
<h2 class="text-2xl font-bold mb-6">๐ ๋ฌธ์ ๋ณ ์์ธ ๊ฒฐ๊ณผ</h2>
@foreach($results as $index => $result)
<div class="mb-6 pb-6 border-b">
<div class="flex items-start justify-between">
<h3 class="text-lg font-bold mb-2">{{ $index + 1 }}. {{ $result['question']->question_text }}</h3>
@if($result['is_correct'])
<span class="bg-green-100 text-green-800 text-xs px-2 py-1 rounded">โ
์ ๋ต</span>
@else
<span class="bg-red-100 text-red-800 text-xs px-2 py-1 rounded">โ ์ค๋ต</span>
@endif
</div>
<p class="text-sm text-gray-600 mt-2">๋น์ ์ ๋ต: <strong>{{ $result['user_answer'] ?? '๋ฏธ์๋ต' }}</strong></p>
@if(!$result['is_correct'])
<p class="text-sm text-green-600 mt-1">์ ๋ต: <strong>{{ $result['question']->correct_answer }}</strong></p>
@endif
</div>
@endforeach
</div>
<div class="mt-6 flex gap-4">
<a href="{{ route('home') }}" class="flex-1 bg-gray-600 text-white text-center py-3 rounded-md hover:bg-gray-700">
๐ ํ์ผ๋ก
</a>
@if($exam->allow_retake)
<a href="{{ route('exams.show', $exam) }}" class="flex-1 bg-blue-600 text-white text-center py-3 rounded-md hover:bg-blue-700">
๐ ๋ค์ ๋์ ํ๊ธฐ
</a>
@endif
</div>
</div>
</div>
</x-app-layout>
```
4. **๋ง์ดํ์ด์ง ๋ทฐ** (`resources/views/exams/my.blade.php`):
```blade
<x-app-layout>
<div class="py-12">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
<h1 class="text-3xl font-bold mb-8">๐ ๋์ ์์ ๊ธฐ๋ก</h1>
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<table class="w-full">
<thead class="bg-gray-100">
<tr>
<th class="px-6 py-3 text-left">์ํ๋ช
</th>
<th class="px-6 py-3 text-left">์์์ผ</th>
<th class="px-6 py-3 text-left">์ ์</th>
<th class="px-6 py-3 text-left">๊ฒฐ๊ณผ</th>
<th class="px-6 py-3 text-left">์์ธ</th>
</tr>
</thead>
<tbody>
@forelse(auth()->user()->examAttempts()->with('exam')->latest()->get() as $attempt)
<tr class="border-b">
<td class="px-6 py-4">{{ $attempt->exam->title }}</td>
<td class="px-6 py-4">{{ $attempt->created_at->format('Y-m-d H:i') }}</td>
<td class="px-6 py-4 font-bold">{{ $attempt->score }}์ </td>
<td class="px-6 py-4">
@if($attempt->passed)
<span class="bg-green-100 text-green-800 text-xs px-2 py-1 rounded">โ
ํฉ๊ฒฉ</span>
@else
<span class="bg-red-100 text-red-800 text-xs px-2 py-1 rounded">โ ๋ถํฉ๊ฒฉ</span>
@endif
</td>
<td class="px-6 py-4">
<a href="{{ route('exams.result', $attempt) }}" class="text-blue-600 hover:underline">๊ฒฐ๊ณผ ๋ณด๊ธฐ</a>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-6 py-8 text-center text-gray-500">์์ง ์์ํ ์ํ์ด ์์ต๋๋ค.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</x-app-layout>
```
5. **๋ค๋น๊ฒ์ด์
์ ๋ง์ดํ์ด์ง ๋งํฌ ์ถ๊ฐ** (`resources/views/layouts/navigation.blade.php`):
```blade
<x-nav-link :href="route('exams.my')" :active="request()->routeIs('exams.my')">
๋์ ์ํ ๊ธฐ๋ก
</x-nav-link>
```
๊ธฐ๋ ๊ฒฐ๊ณผ: ๊ฒฐ๊ณผ ํ์ด์ง์์ ํฉ๊ฒฉ/๋ถํฉ๊ฒฉ ์ฌ๋ถ, ์ด์ , ์ ๋ต๋ฅ ํ์. ๋ฌธ์ ๋ณ ์ ์ค๋ต ์์ธ ๋ถ์ ํ์. ๋ง์ดํ์ด์ง์์ ์์ ๊ธฐ๋ก ํ ์ด๋ธ ํํ๋ก ํ์ธ ๊ฐ๋ฅ. ๋ค๋น๊ฒ์ด์ ์ ๋ง์ดํ์ด์ง ๋งํฌ ์ถ๊ฐ.
๊ด๋ฆฌ์ ํจ๋ ๊ตฌํ (์ํ/๋ฌธ์ CRUD):
1. **Policy ์์ฑ** (๊ด๋ฆฌ์๋ง ์ ๊ทผ):
```bash
php artisan make:policy ExamPolicy --model=Exam
```
`app/Policies/ExamPolicy.php`:
```php
public function viewAny(User $user)
{
return $user->role === 'admin';
}
public function create(User $user)
{
return $user->role === 'admin';
}
public function update(User $user, Exam $exam)
{
return $user->role === 'admin';
}
public function delete(User $user, Exam $exam)
{
return $user->role === 'admin';
}
```
2. **AdminExamController ์์ฑ**:
```bash
php artisan make:controller Admin/ExamController --resource
```
`app/Http/Controllers/Admin/ExamController.php`:
```php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Exam;
use Illuminate\Http\Request;
public function __construct()
{
$this->authorizeResource(Exam::class, 'exam');
}
public function index()
{
$exams = Exam::withCount('questions', 'attempts')->latest()->paginate(10);
return view('admin.exams.index', compact('exams'));
}
public function create()
{
return view('admin.exams.create');
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|max:255',
'description' => 'nullable',
'duration_minutes' => 'required|integer|min:1',
'passing_score' => 'required|integer|min:0|max:100',
'category' => 'required|max:100',
'difficulty' => 'required|in:beginner,intermediate,advanced',
'status' => 'required|in:draft,published',
'allow_retake' => 'boolean',
]);
Exam::create($validated);
return redirect()->route('admin.exams.index')->with('success', '์ํ์ด ์์ฑ๋์์ต๋๋ค.');
}
public function edit(Exam $exam)
{
return view('admin.exams.edit', compact('exam'));
}
public function update(Request $request, Exam $exam)
{
$validated = $request->validate([
'title' => 'required|max:255',
'description' => 'nullable',
'duration_minutes' => 'required|integer|min:1',
'passing_score' => 'required|integer|min:0|max:100',
'category' => 'required|max:100',
'difficulty' => 'required|in:beginner,intermediate,advanced',
'status' => 'required|in:draft,published',
'allow_retake' => 'boolean',
]);
$exam->update($validated);
return redirect()->route('admin.exams.index')->with('success', '์ํ์ด ์์ ๋์์ต๋๋ค.');
}
public function destroy(Exam $exam)
{
$exam->delete();
return redirect()->route('admin.exams.index')->with('success', '์ํ์ด ์ญ์ ๋์์ต๋๋ค.');
}
```
3. **๋ผ์ฐํธ ์ค์ ** (`routes/web.php`):
```php
use App\Http\Controllers\Admin\ExamController as AdminExamController;
Route::middleware(['auth'])->prefix('admin')->name('admin.')->group(function () {
Route::resource('exams', AdminExamController::class);
});
```
4. **Blade ๋ทฐ** (`resources/views/admin/exams/index.blade.php`):
```blade
<x-app-layout>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold">๐ ์ํ ๊ด๋ฆฌ</h1>
<a href="{{ route('admin.exams.create') }}" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700">
+ ์ ์ํ ๋ง๋ค๊ธฐ
</a>
</div>
@if(session('success'))
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
{{ session('success') }}
</div>
@endif
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<table class="w-full">
<thead class="bg-gray-100">
<tr>
<th class="px-6 py-3 text-left">์ ๋ชฉ</th>
<th class="px-6 py-3 text-left">์นดํ
๊ณ ๋ฆฌ</th>
<th class="px-6 py-3 text-left">๋ฌธ์ ์</th>
<th class="px-6 py-3 text-left">์์์ ์</th>
<th class="px-6 py-3 text-left">์ํ</th>
<th class="px-6 py-3 text-left">๊ด๋ฆฌ</th>
</tr>
</thead>
<tbody>
@foreach($exams as $exam)
<tr class="border-b">
<td class="px-6 py-4 font-bold">{{ $exam->title }}</td>
<td class="px-6 py-4">{{ $exam->category }}</td>
<td class="px-6 py-4">{{ $exam->questions_count }}๋ฌธ์ </td>
<td class="px-6 py-4">{{ $exam->attempts_count }}๋ช
</td>
<td class="px-6 py-4">
@if($exam->status === 'published')
<span class="bg-green-100 text-green-800 text-xs px-2 py-1 rounded">๊ณต๊ฐ</span>
@else
<span class="bg-gray-100 text-gray-800 text-xs px-2 py-1 rounded">๋น๊ณต๊ฐ</span>
@endif
</td>
<td class="px-6 py-4 flex gap-2">
<a href="{{ route('admin.exams.edit', $exam) }}" class="text-blue-600 hover:underline">์์ </a>
<form method="POST" action="{{ route('admin.exams.destroy', $exam) }}" onsubmit="return confirm('์ ๋ง ์ญ์ ํ์๊ฒ ์ต๋๊น?')">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:underline">์ญ์ </button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="mt-6">
{{ $exams->links() }}
</div>
</div>
</div>
</x-app-layout>
```
5. **์ํ ์์ฑ/์์ ํผ** (`resources/views/admin/exams/create.blade.php`, `edit.blade.php`):
```blade
<x-app-layout>
<div class="py-12">
<div class="max-w-3xl mx-auto sm:px-6 lg:px-8">
<h1 class="text-3xl font-bold mb-8">{{ isset($exam) ? '์ํ ์์ ' : '์ ์ํ ๋ง๋ค๊ธฐ' }}</h1>
<form method="POST" action="{{ isset($exam) ? route('admin.exams.update', $exam) : route('admin.exams.store') }}" class="bg-white rounded-lg shadow-md p-8">
@csrf
@if(isset($exam))
@method('PUT')
@endif
<div class="mb-6">
<label class="block text-sm font-bold mb-2">์ํ ์ ๋ชฉ *</label>
<input type="text" name="title" value="{{ old('title', $exam->title ?? '') }}" class="w-full border-gray-300 rounded-md" required>
@error('title')<p class="text-red-600 text-sm mt-1">{{ $message }}</p>@enderror
</div>
<div class="mb-6">
<label class="block text-sm font-bold mb-2">์ค๋ช
</label>
<textarea name="description" rows="4" class="w-full border-gray-300 rounded-md">{{ old('description', $exam->description ?? '') }}</textarea>
</div>
<div class="grid grid-cols-2 gap-4 mb-6">
<div>
<label class="block text-sm font-bold mb-2">์ ํ ์๊ฐ (๋ถ) *</label>
<input type="number" name="duration_minutes" value="{{ old('duration_minutes', $exam->duration_minutes ?? 30) }}" class="w-full border-gray-300 rounded-md" required>
</div>
<div>
<label class="block text-sm font-bold mb-2">ํฉ๊ฒฉ ์ ์ *</label>
<input type="number" name="passing_score" value="{{ old('passing_score', $exam->passing_score ?? 70) }}" class="w-full border-gray-300 rounded-md" required>
</div>
</div>
<div class="grid grid-cols-2 gap-4 mb-6">
<div>
<label class="block text-sm font-bold mb-2">์นดํ
๊ณ ๋ฆฌ *</label>
<input type="text" name="category" value="{{ old('category', $exam->category ?? '') }}" class="w-full border-gray-300 rounded-md" required>
</div>
<div>
<label class="block text-sm font-bold mb-2">๋์ด๋ *</label>
<select name="difficulty" class="w-full border-gray-300 rounded-md" required>
<option value="beginner" {{ old('difficulty', $exam->difficulty ?? '') === 'beginner' ? 'selected' : '' }}>์ด๊ธ</option>
<option value="intermediate" {{ old('difficulty', $exam->difficulty ?? '') === 'intermediate' ? 'selected' : '' }}>์ค๊ธ</option>
<option value="advanced" {{ old('difficulty', $exam->difficulty ?? '') === 'advanced' ? 'selected' : '' }}>๊ณ ๊ธ</option>
</select>
</div>
</div>
<div class="mb-6">
<label class="block text-sm font-bold mb-2">๊ณต๊ฐ ์ํ *</label>
<select name="status" class="w-full border-gray-300 rounded-md" required>
<option value="draft" {{ old('status', $exam->status ?? '') === 'draft' ? 'selected' : '' }}>๋น๊ณต๊ฐ</option>
<option value="published" {{ old('status', $exam->status ?? '') === 'published' ? 'selected' : '' }}>๊ณต๊ฐ</option>
</select>
</div>
<div class="mb-6">
<label class="flex items-center">
<input type="checkbox" name="allow_retake" value="1" {{ old('allow_retake', $exam->allow_retake ?? true) ? 'checked' : '' }} class="mr-2">
<span class="text-sm">์ฌ์์ ํ์ฉ</span>
</label>
</div>
<button type="submit" class="w-full bg-blue-600 text-white py-3 rounded-md font-bold hover:bg-blue-700">
{{ isset($exam) ? '์์ ํ๊ธฐ' : '์์ฑํ๊ธฐ' }}
</button>
</form>
</div>
</div>
</x-app-layout>
```
๊ธฐ๋ ๊ฒฐ๊ณผ: ๊ด๋ฆฌ์ ์ ์ฉ ์ํ ๊ด๋ฆฌ ํ์ด์ง ๊ตฌํ ์๋ฃ. Policy๋ก ๊ถํ ์ ์ด (๊ด๋ฆฌ์๋ง ์ ๊ทผ). ์ํ ๋ชฉ๋ก, ์์ฑ, ์์ , ์ญ์ CRUD ๊ธฐ๋ฅ ์๋. ์ํ ์ ๋ณด ํผ ์ ํจ์ฑ ๊ฒ์ฆ ์ ์ฉ.
๋ฌธ์ ๊ด๋ฆฌ ์์คํ
๊ตฌํ:
1. **QuestionController ์์ฑ**:
```bash
php artisan make:controller Admin/QuestionController --resource
```
`app/Http/Controllers/Admin/QuestionController.php`:
```php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Exam;
use App\Models\Question;
use Illuminate\Http\Request;
public function index(Exam $exam)
{
$this->authorize('update', $exam);
$questions = $exam->questions()->orderBy('order')->get();
return view('admin.questions.index', compact('exam', 'questions'));
}
public function create(Exam $exam)
{
$this->authorize('update', $exam);
return view('admin.questions.create', compact('exam'));
}
public function store(Request $request, Exam $exam)
{
$this->authorize('update', $exam);
$validated = $request->validate([
'question_text' => 'required',
'question_type' => 'required|in:multiple_choice,short_answer',
'options' => 'required_if:question_type,multiple_choice|array|min:2',
'options.*' => 'required_if:question_type,multiple_choice|string',
'correct_answer' => 'required',
'points' => 'required|integer|min:1',
]);
$validated['exam_id'] = $exam->id;
$validated['order'] = $exam->questions()->max('order') + 1;
$validated['options'] = $request->question_type === 'multiple_choice' ? json_encode($request->options) : null;
Question::create($validated);
return redirect()->route('admin.questions.index', $exam)->with('success', '๋ฌธ์ ๊ฐ ์ถ๊ฐ๋์์ต๋๋ค.');
}
public function edit(Exam $exam, Question $question)
{
$this->authorize('update', $exam);
return view('admin.questions.edit', compact('exam', 'question'));
}
public function update(Request $request, Exam $exam, Question $question)
{
$this->authorize('update', $exam);
$validated = $request->validate([
'question_text' => 'required',
'question_type' => 'required|in:multiple_choice,short_answer',
'options' => 'required_if:question_type,multiple_choice|array|min:2',
'options.*' => 'required_if:question_type,multiple_choice|string',
'correct_answer' => 'required',
'points' => 'required|integer|min:1',
]);
$validated['options'] = $request->question_type === 'multiple_choice' ? json_encode($request->options) : null;
$question->update($validated);
return redirect()->route('admin.questions.index', $exam)->with('success', '๋ฌธ์ ๊ฐ ์์ ๋์์ต๋๋ค.');
}
public function destroy(Exam $exam, Question $question)
{
$this->authorize('update', $exam);
$question->delete();
return redirect()->route('admin.questions.index', $exam)->with('success', '๋ฌธ์ ๊ฐ ์ญ์ ๋์์ต๋๋ค.');
}
```
2. **๋ผ์ฐํธ ์ถ๊ฐ**:
```php
Route::middleware(['auth'])->prefix('admin')->name('admin.')->group(function () {
Route::resource('exams', AdminExamController::class);
Route::resource('exams.questions', QuestionController::class)->except(['show']);
});
```
3. **๋ฌธ์ ๋ชฉ๋ก ๋ทฐ** (`resources/views/admin/questions/index.blade.php`):
```blade
<x-app-layout>
<div class="py-12">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-3xl font-bold">๐ ๋ฌธ์ ๊ด๋ฆฌ</h1>
<p class="text-gray-600 mt-2">์ํ: {{ $exam->title }}</p>
</div>
<div class="flex gap-2">
<a href="{{ route('admin.exams.index') }}" class="bg-gray-600 text-white px-4 py-2 rounded-md hover:bg-gray-700">โ ์ํ ๋ชฉ๋ก</a>
<a href="{{ route('admin.questions.create', $exam) }}" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700">+ ๋ฌธ์ ์ถ๊ฐ</a>
</div>
</div>
@if(session('success'))
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
{{ session('success') }}
</div>
@endif
<div class="space-y-4">
@forelse($questions as $question)
<div class="bg-white rounded-lg shadow-md p-6">
<div class="flex justify-between items-start">
<div class="flex-1">
<h3 class="text-lg font-bold mb-2">{{ $question->order }}. {{ $question->question_text }}</h3>
@if($question->question_type === 'multiple_choice')
<div class="space-y-1 text-sm text-gray-600">
@foreach(json_decode($question->options) as $option)
<p class="{{ $option === $question->correct_answer ? 'text-green-600 font-bold' : '' }}">
{{ $option === $question->correct_answer ? 'โ
' : 'โ' }} {{ $option }}
</p>
@endforeach
</div>
@else
<p class="text-sm text-green-600">์ ๋ต: {{ $question->correct_answer }}</p>
@endif
<p class="text-xs text-gray-500 mt-2">๋ฐฐ์ : {{ $question->points }}์ </p>
</div>
<div class="flex gap-2">
<a href="{{ route('admin.questions.edit', [$exam, $question]) }}" class="text-blue-600 hover:underline text-sm">์์ </a>
<form method="POST" action="{{ route('admin.questions.destroy', [$exam, $question]) }}" onsubmit="return confirm('์ ๋ง ์ญ์ ํ์๊ฒ ์ต๋๊น?')">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:underline text-sm">์ญ์ </button>
</form>
</div>
</div>
</div>
@empty
<div class="bg-white rounded-lg shadow-md p-8 text-center text-gray-500">
์์ง ๋ฑ๋ก๋ ๋ฌธ์ ๊ฐ ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋ฌธ์ ๋ฅผ ์ถ๊ฐํด๋ณด์ธ์!
</div>
@endforelse
</div>
</div>
</div>
</x-app-layout>
```
4. **๋ฌธ์ ์์ฑ/์์ ํผ** (`resources/views/admin/questions/create.blade.php`, `edit.blade.php`):
```blade
<x-app-layout>
<div class="py-12">
<div class="max-w-3xl mx-auto sm:px-6 lg:px-8">
<h1 class="text-3xl font-bold mb-8">{{ isset($question) ? '๋ฌธ์ ์์ ' : '์ ๋ฌธ์ ์ถ๊ฐ' }}</h1>
<form method="POST" action="{{ isset($question) ? route('admin.questions.update', [$exam, $question]) : route('admin.questions.store', $exam) }}" class="bg-white rounded-lg shadow-md p-8" x-data="questionForm()">
@csrf
@if(isset($question))
@method('PUT')
@endif
<div class="mb-6">
<label class="block text-sm font-bold mb-2">๋ฌธ์ ์ ํ *</label>
<select name="question_type" x-model="questionType" class="w-full border-gray-300 rounded-md" required>
<option value="multiple_choice">๊ฐ๊ด์</option>
<option value="short_answer">์ฃผ๊ด์</option>
</select>
</div>
<div class="mb-6">
<label class="block text-sm font-bold mb-2">๋ฌธ์ ๋ด์ฉ *</label>
<textarea name="question_text" rows="4" class="w-full border-gray-300 rounded-md" required>{{ old('question_text', $question->question_text ?? '') }}</textarea>
</div>
<div class="mb-6" x-show="questionType === 'multiple_choice'">
<label class="block text-sm font-bold mb-2">์ ํ์ง (์ต์ 2๊ฐ) *</label>
<template x-for="(option, index) in options" :key="index">
<div class="flex gap-2 mb-2">
<input type="text" :name="'options[' + index + ']'" x-model="options[index]" class="flex-1 border-gray-300 rounded-md" placeholder="์ ํ์ง ์
๋ ฅ">
<button type="button" @click="options.splice(index, 1)" class="bg-red-600 text-white px-3 py-2 rounded-md" x-show="options.length > 2">์ญ์ </button>
</div>
</template>
<button type="button" @click="options.push('')" class="bg-gray-600 text-white px-4 py-2 rounded-md mt-2">+ ์ ํ์ง ์ถ๊ฐ</button>
</div>
<div class="mb-6">
<label class="block text-sm font-bold mb-2">์ ๋ต *</label>
<input type="text" name="correct_answer" value="{{ old('correct_answer', $question->correct_answer ?? '') }}" class="w-full border-gray-300 rounded-md" required>
<p class="text-xs text-gray-500 mt-1">๊ฐ๊ด์์ธ ๊ฒฝ์ฐ ์ ์ ํ์ง ์ค ํ๋๋ฅผ ์ ํํ ์
๋ ฅํ์ธ์.</p>
</div>
<div class="mb-6">
<label class="block text-sm font-bold mb-2">๋ฐฐ์ *</label>
<input type="number" name="points" value="{{ old('points', $question->points ?? 10) }}" class="w-full border-gray-300 rounded-md" required>
</div>
<button type="submit" class="w-full bg-blue-600 text-white py-3 rounded-md font-bold hover:bg-blue-700">
{{ isset($question) ? '์์ ํ๊ธฐ' : '์ถ๊ฐํ๊ธฐ' }}
</button>
</form>
</div>
</div>
<script>
function questionForm() {
return {
questionType: '{{ old('question_type', $question->question_type ?? 'multiple_choice') }}',
options: @json(old('options', isset($question) && $question->options ? json_decode($question->options) : ['', '']))
}
}
</script>
</x-app-layout>
```
๊ธฐ๋ ๊ฒฐ๊ณผ: ๋ฌธ์ ๊ด๋ฆฌ ์์คํ ๊ตฌํ ์๋ฃ. ์ํ๋ณ ๋ฌธ์ ๋ชฉ๋ก ์กฐํ, ๋ฌธ์ ์ถ๊ฐ/์์ /์ญ์ CRUD ๊ธฐ๋ฅ ์๋. ๊ฐ๊ด์/์ฃผ๊ด์ ๋ฌธ์ ์ ํ ์ ํ ๊ฐ๋ฅ. Alpine.js๋ก ๋์ ์ ํ์ง ์ถ๊ฐ/์ญ์ ๊ธฐ๋ฅ ๊ตฌํ.
ํต๊ณ ๋์๋ณด๋ ๋ฐ ๋ณด๋์ค ๊ธฐ๋ฅ ๊ตฌํ:
1. **ํต๊ณ ๋์๋ณด๋ ์ถ๊ฐ** (`app/Http/Controllers/Admin/DashboardController.php`):
```bash
php artisan make:controller Admin/DashboardController
```
```php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Exam;
use App\Models\ExamAttempt;
use Illuminate\Support\Facades\DB;
public function index()
{
$this->authorize('viewAny', Exam::class);
$stats = [
'total_exams' => Exam::count(),
'total_attempts' => ExamAttempt::count(),
'pass_rate' => ExamAttempt::whereNotNull('submitted_at')->avg('passed') * 100,
'avg_score' => ExamAttempt::whereNotNull('submitted_at')->avg('score'),
];
$recentAttempts = ExamAttempt::with(['user', 'exam'])
->whereNotNull('submitted_at')
->latest()
->take(10)
->get();
$examStats = Exam::withCount('attempts')
->withAvg('attempts', 'score')
->get()
->map(function ($exam) {
return [
'title' => $exam->title,
'attempts' => $exam->attempts_count,
'avg_score' => round($exam->attempts_avg_score ?? 0, 1),
];
});
return view('admin.dashboard', compact('stats', 'recentAttempts', 'examStats'));
}
```
2. **๋ผ์ฐํธ ์ถ๊ฐ**:
```php
Route::middleware(['auth'])->prefix('admin')->name('admin.')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
// ... ๊ธฐ์กด ๋ผ์ฐํธ
});
```
3. **๋์๋ณด๋ ๋ทฐ** (`resources/views/admin/dashboard.blade.php`):
```blade
<x-app-layout>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<h1 class="text-3xl font-bold mb-8">๐ ๊ด๋ฆฌ์ ๋์๋ณด๋</h1>
<!-- ํต๊ณ ์นด๋ -->
<div class="grid grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-lg shadow-md p-6">
<p class="text-sm text-gray-500 mb-2">์ด ์ํ ์</p>
<p class="text-3xl font-bold">{{ $stats['total_exams'] }}</p>
</div>
<div class="bg-white rounded-lg shadow-md p-6">
<p class="text-sm text-gray-500 mb-2">์ด ์์ ํ์</p>
<p class="text-3xl font-bold">{{ $stats['total_attempts'] }}</p>
</div>
<div class="bg-white rounded-lg shadow-md p-6">
<p class="text-sm text-gray-500 mb-2">ํ๊ท ํฉ๊ฒฉ๋ฅ </p>
<p class="text-3xl font-bold text-green-600">{{ round($stats['pass_rate'], 1) }}%</p>
</div>
<div class="bg-white rounded-lg shadow-md p-6">
<p class="text-sm text-gray-500 mb-2">ํ๊ท ์ ์</p>
<p class="text-3xl font-bold text-blue-600">{{ round($stats['avg_score'], 1) }}์ </p>
</div>
</div>
<div class="grid grid-cols-2 gap-6">
<!-- ์ํ๋ณ ํต๊ณ -->
<div class="bg-white rounded-lg shadow-md p-6">
<h2 class="text-xl font-bold mb-4">์ํ๋ณ ํต๊ณ</h2>
<canvas id="examChart"></canvas>
</div>
<!-- ์ต๊ทผ ์์ ๊ธฐ๋ก -->
<div class="bg-white rounded-lg shadow-md p-6">
<h2 class="text-xl font-bold mb-4">์ต๊ทผ ์์ ๊ธฐ๋ก</h2>
<div class="space-y-2">
@foreach($recentAttempts as $attempt)
<div class="flex justify-between items-center py-2 border-b">
<div>
<p class="font-bold text-sm">{{ $attempt->user->name }}</p>
<p class="text-xs text-gray-500">{{ $attempt->exam->title }}</p>
</div>
<div class="text-right">
<p class="font-bold">{{ $attempt->score }}์ </p>
@if($attempt->passed)
<span class="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">ํฉ๊ฒฉ</span>
@else
<span class="text-xs bg-red-100 text-red-800 px-2 py-1 rounded">๋ถํฉ๊ฒฉ</span>
@endif
</div>
</div>
@endforeach
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
const ctx = document.getElementById('examChart').getContext('2d');
new Chart(ctx, {
type: 'bar',
data: {
labels: @json($examStats->pluck('title')),
datasets: [{
label: 'ํ๊ท ์ ์',
data: @json($examStats->pluck('avg_score')),
backgroundColor: 'rgba(59, 130, 246, 0.5)',
borderColor: 'rgb(59, 130, 246)',
borderWidth: 1
}]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true,
max: 100
}
}
}
});
</script>
</x-app-layout>
```
4. **๋ค๋น๊ฒ์ด์
์ ๊ด๋ฆฌ์ ๋ฉ๋ด ์ถ๊ฐ** (`resources/views/layouts/navigation.blade.php`):
```blade
@if(auth()->user()->role === 'admin')
<x-nav-link :href="route('admin.dashboard')" :active="request()->routeIs('admin.dashboard')">
๊ด๋ฆฌ์ ๋์๋ณด๋
</x-nav-link>
<x-nav-link :href="route('admin.exams.index')" :active="request()->routeIs('admin.exams.*')">
์ํ ๊ด๋ฆฌ
</x-nav-link>
@endif
```
5. **๋ถ์ ํ์ ๋ฐฉ์ง ๊ธฐ๋ฅ ์ถ๊ฐ** (์ํ ์์ ํ์ด์ง์ ํญ ์ ํ ๊ฐ์ง):
`resources/views/exams/take.blade.php`์ Alpine.js ๋ฐ์ดํฐ์ ์ถ๊ฐ:
```javascript
warnings: 0,
init() {
// ๊ธฐ์กด init ์ฝ๋...
// ํญ ์ ํ ๊ฐ์ง
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.warnings++;
if (this.warnings >= 3) {
alert('๋ถ์ ํ์๊ฐ ์์ฌ๋์ด ์ํ์ด ์๋ ์ ์ถ๋ฉ๋๋ค.');
document.querySelector('form').submit();
} else {
alert(`๊ฒฝ๊ณ : ํญ ์ ํ์ด ๊ฐ์ง๋์์ต๋๋ค. (${this.warnings}/3)`);
}
}
});
}
```
๊ธฐ๋ ๊ฒฐ๊ณผ: ๊ด๋ฆฌ์ ๋์๋ณด๋ ๊ตฌํ ์๋ฃ. ์ด ์ํ ์, ์์ ํ์, ํฉ๊ฒฉ๋ฅ , ํ๊ท ์ ์ ํต๊ณ ํ์. Chart.js๋ก ์ํ๋ณ ํ๊ท ์ ์ ์ฐจํธ ์๊ฐํ. ์ต๊ทผ ์์ ๊ธฐ๋ก ๋ชฉ๋ก ํ์. ๋ค๋น๊ฒ์ด์ ์ ๊ด๋ฆฌ์ ๋ฉ๋ด ์ถ๊ฐ. ์ํ ์์ ์ค ํญ ์ ํ ๊ฐ์ง ๋ฐ ๊ฒฝ๊ณ ๊ธฐ๋ฅ ๊ตฌํ.
์ต์ข
๋ง๋ฌด๋ฆฌ ๋ฐ UI/UX ๊ฐ์ :
1. **๋คํฌ๋ชจ๋ ํ ๊ธ ์ถ๊ฐ** (`resources/views/layouts/app.blade.php`):
`<head>` ํ๊ทธ ์์ ๋คํฌ๋ชจ๋ ์คํฌ๋ฆฝํธ ์ถ๊ฐ:
```html
<script>
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
</script>
```
๋ค๋น๊ฒ์ด์
์ ๋คํฌ๋ชจ๋ ํ ๊ธ ๋ฒํผ ์ถ๊ฐ:
```blade
<button @click="toggleDarkMode()" class="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800">
<span x-show="!darkMode">๐</span>
<span x-show="darkMode">โ๏ธ</span>
</button>
<script>
function toggleDarkMode() {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.theme = 'light';
} else {
document.documentElement.classList.add('dark');
localStorage.theme = 'dark';
}
}
</script>
```
2. **Tailwind ๋คํฌ๋ชจ๋ ์ค์ ** (`tailwind.config.js`):
```javascript
module.exports = {
darkMode: 'class',
// ... ๊ธฐ์กด ์ค์
}
```
3. **๋ฐ์ํ ๊ฐ์ ๋ฐ ์ ๋๋ฉ์ด์
์ถ๊ฐ**:
์ํ ์นด๋ ํธ๋ฒ ํจ๊ณผ ๊ฐํ:
```blade
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 hover:shadow-xl hover:scale-105 transition-all duration-300">
```
ํ์ด๋จธ ์ ๋๋ฉ์ด์
:
```blade
<p class="text-2xl font-bold transition-colors duration-300"
:class="timeLeft < 300 ? 'text-red-600 animate-pulse' : 'text-green-600'"
x-text="formatTime(timeLeft)">
</p>
```
4. **์๋ฌ ํธ๋ค๋ง ๊ฐ์ **:
`app/Exceptions/Handler.php`์์ 403, 404 ์๋ฌ ์ปค์คํ
ํ์ด์ง ์ถ๊ฐ:
```php
public function render($request, Throwable $exception)
{
if ($exception instanceof \Symfony\Component\HttpKernel\Exception\NotFoundHttpException) {
return response()->view('errors.404', [], 404);
}
if ($exception instanceof \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException) {
return response()->view('errors.403', [], 403);
}
return parent::render($request, $exception);
}
```
5. **README.md ์์ฑ**:
```markdown
# ๐ ์จ๋ผ์ธ ์๊ฒฉ์ฆ ์ํ ํ๋ซํผ
Laravel 13.x + Blade ํ
ํ๋ฆฟ์ผ๋ก ๊ตฌํํ ์ค์๊ฐ ํ์ด๋จธ ๊ธฐ๋ฐ ์จ๋ผ์ธ ์ํ ์์ ์์คํ
์
๋๋ค.
## ์ฃผ์ ๊ธฐ๋ฅ
- โ
์ค์๊ฐ ์นด์ดํธ๋ค์ด ํ์ด๋จธ (์๊ฐ ์ด๊ณผ ์ ์๋ ์ ์ถ)
- โ
๊ฐ๊ด์/์ฃผ๊ด์ ๋ฌธ์ ์ง์
- โ
์๋ ์ฑ์ ์์คํ
- โ
ํฉ๊ฒฉ/๋ถํฉ๊ฒฉ ํ์ ๋ฐ ๊ฒฐ๊ณผ ๋ฆฌํฌํธ
- โ
๊ด๋ฆฌ์ ์ํ/๋ฌธ์ ๊ด๋ฆฌ CRUD
- โ
์์ ๊ธฐ๋ก ๋ฐ ํต๊ณ ๋์๋ณด๋
- โ
๋ถ์ ํ์ ๋ฐฉ์ง (ํญ ์ ํ ๊ฐ์ง)
- โ
๋คํฌ๋ชจ๋ ์ง์
## ์ค์น ๋ฐฉ๋ฒ
1. ์์กด์ฑ ์ค์น:
```bash
composer install
npm install
```
2. ํ๊ฒฝ ์ค์ :
```bash
cp .env.example .env
php artisan key:generate
```
3. ๋ฐ์ดํฐ๋ฒ ์ด์ค ๋ง์ด๊ทธ๋ ์ด์
๋ฐ ์๋:
```bash
touch database/database.sqlite
php artisan migrate --seed
```
4. ๊ฐ๋ฐ ์๋ฒ ์คํ:
```bash
php artisan serve
npm run dev
```
## ๊ธฐ๋ณธ ๊ณ์
- **๊ด๋ฆฌ์**: admin@example.com / password
- **์์์**: examinee@example.com / password
## ๊ธฐ์ ์คํ
- Laravel 13.x
- Blade ํ
ํ๋ฆฟ
- Tailwind CSS 4.x
- Alpine.js
- Chart.js
- SQLite
## ๋ผ์ด์ ์ค
MIT License
```
6. **์ต์ข
ํ
์คํธ**:
```bash
# ์บ์ ํด๋ฆฌ์ด
php artisan config:clear
php artisan cache:clear
php artisan view:clear
# ํ๋ก๋์
๋น๋
npm run build
# ๋ชจ๋ ๊ธฐ๋ฅ ํ
์คํธ:
# 1. ํ์๊ฐ์
/๋ก๊ทธ์ธ
# 2. ์ํ ๋ชฉ๋ก ์กฐํ ๋ฐ ํํฐ๋ง
# 3. ์ํ ์์ (ํ์ด๋จธ ํ์ธ)
# 4. ๋ต์ ์๋ ์ ์ฅ ํ์ธ
# 5. ์ํ ์ ์ถ ๋ฐ ๊ฒฐ๊ณผ ํ์ธ
# 6. ๊ด๋ฆฌ์ ๋ก๊ทธ์ธ ํ ์ํ/๋ฌธ์ CRUD
# 7. ํต๊ณ ๋์๋ณด๋ ํ์ธ
# 8. ๋คํฌ๋ชจ๋ ํ ๊ธ
```
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ต์ข ๋ง๋ฌด๋ฆฌ ์๋ฃ. ๋คํฌ๋ชจ๋ ํ ๊ธ ๊ธฐ๋ฅ ์ถ๊ฐ. ๋ฐ์ํ ๋์์ธ ๋ฐ ์ ๋๋ฉ์ด์ ๊ฐํ. ์๋ฌ ํ์ด์ง ์ปค์คํฐ๋ง์ด์ง. README.md ์์ฑ์ผ๋ก ํ๋ก์ ํธ ๋ฌธ์ํ. ๋ชจ๋ ๊ธฐ๋ฅ์ด ์ ์ ์๋ํ๋ ์์ฑ๋ ์จ๋ผ์ธ ์๊ฒฉ์ฆ ์ํ ํ๋ซํผ ๊ตฌํ ์๋ฃ.
์ ์ถ๋ ์ํ (0)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!