๐ ์จ๋ผ์ธ ๊ฐ์ข ์๊ฐ์ ์๋ฃ์ฆ ์๋ ๋ฐ๊ธ ์์คํ
course-completion-certificate-automation
๋ฌธ์ ์ค๋ช
๐ ํ๋ก์ ํธ ๊ฐ์
์จ๋ผ์ธ ๊ต์ก ํ๋ซํผ์์ ์๊ฐ์์ด ๊ฐ์ข๋ฅผ ์๋ฃํ๋ฉด ์๋์ผ๋ก ์๋ฃ์ฆ์ ์์ฑํ๊ณ ๋ฐ๊ธํ๋ ํ์คํ ์์คํ ์ ๋ง๋ญ๋๋ค. Laravel 13.x + Inertia.js + React 19 ๋ชจ๋ ธ๋ฆฌ์ค ์ํคํ ์ฒ๋ก ๊ตฌํํ๋ฉฐ, ๊ด๋ฆฌ์๊ฐ ์๋ ์ค์ ์๊ฐ์์ด ์ฌ์ฉํ๋ ์๋น์ค์ ๋๋ค.
โจ ํต์ฌ ๊ธฐ๋ฅ
ํ์ ๊ธฐ๋ฅ
- ๊ฐ์ข ๋ชฉ๋ก ํ์ด์ง: ์๊ฐ ๊ฐ๋ฅํ ๊ฐ์ข ๋ชฉ๋ก์ ์นด๋ ํํ๋ก ํ์ (์ธ๋ค์ผ, ์ ๋ชฉ, ๊ฐ์ฌ๋ช , ์๊ฐ ์๊ฐ)
- ๊ฐ์ข ์์ธ ํ์ด์ง: ์ปค๋ฆฌํ๋ผ, ๊ฐ์ฌ ์๊ฐ, ์๊ฐ ์์ ๋ฒํผ
- ํ์ต ์งํ ํธ๋์ปค: ๊ฐ ๊ฐ์ ์์์ ์ฒดํฌ๋ฆฌ์คํธ๋ก ํ์ํ๊ณ , ์์ฒญ ์๋ฃ ์ ์ฒดํฌ ํ์
- ์งํ๋ฅ ์๊ฐํ: ํ๋ก๊ทธ๋ ์ค ๋ฐ๋ก ์ ์ฒด ๊ฐ์ข ์งํ๋ฅ ํ์ (์: 75% ์๋ฃ)
- ์๋ฃ์ฆ ์๋ ์์ฑ: 100% ์๋ฃ ์ ์๊ฐ์ ์ด๋ฆ, ๊ฐ์ข๋ช , ์๋ฃ ๋ ์ง๊ฐ ํฌํจ๋ ์๋ฃ์ฆ HTML ํ์ด์ง ์์ฑ
- ์๋ฃ์ฆ ๋ค์ด๋ก๋: ์์ฑ๋ ์๋ฃ์ฆ์ PDF๋ก ๋ค์ด๋ก๋ (DomPDF ๋ผ์ด๋ธ๋ฌ๋ฆฌ ํ์ฉ)
- ์๋ฃ ์ด๋ ฅ: ๋ด๊ฐ ์๋ฃํ ๊ฐ์ข ๋ชฉ๋ก๊ณผ ์๋ฃ์ฆ ์ฌ๋ค์ด๋ก๋ ๊ธฐ๋ฅ
๋ณด๋์ค ๊ธฐ๋ฅ (์ ํ)
- ์๋ฃ์ฆ์ QR ์ฝ๋ ์ฝ์ (์๋ฃ ์ง์ ํ์ธ์ฉ URL)
- ์๋ฃ์ฆ ํ ํ๋ฆฟ ์ ํ (ํด๋์, ๋ชจ๋, ๋ฏธ๋๋ฉ ๋์์ธ)
- ์๋ฃ์ฆ SNS ๊ณต์ ๋ฒํผ (์ด๋ฏธ์ง ํํ๋ก ๋ณํ)
- ๊ฐ์ข ๋ฆฌ๋ทฐ ์์ฑ ๊ธฐ๋ฅ (๋ณ์ + ํ ์คํธ ํ๊ธฐ)
- ์ด๋ฉ์ผ๋ก ์๋ฃ์ฆ ์๋ ๋ฐ์ก
๐ฏ ํ์ต ๋ชฉํ
- Laravel Eloquent ๊ด๊ณ ์ค์ (Course, Lesson, Enrollment, Certificate)
- Inertia.js๋ก SPA ๋ผ์ฐํ ๊ตฌํ (ํ์ด์ง ์ ํ ์ ๊น๋นก์ ์์)
- React ์ปดํฌ๋ํธ ์ํ ๊ด๋ฆฌ (์งํ๋ฅ ๊ณ์ฐ, ์ฒดํฌ๋ฐ์ค ์ํ)
- PDF ์์ฑ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ํตํฉ (DomPDF)
- ์กฐ๊ฑด๋ถ ๋ ๋๋ง (์๋ฃ ์ /ํ UI ๋ณํ)
๐ ๏ธ ๊ธฐ์ ์คํ
- ๋ฐฑ์๋: Laravel 13.x (PHP 8.3)
- ํ๋ก ํธ์๋: React 19 + Inertia.js
- ์คํ์ผ๋ง: Tailwind CSS
- ๋ฐ์ดํฐ๋ฒ ์ด์ค: SQLite (๊ฐ๋ฐ์ฉ) / MySQL (ํ๋ก๋์ ๊ฐ๋ฅ)
- PDF ์์ฑ: barryvdh/laravel-dompdf
- ์ธ์ฆ: Laravel Breeze (React + Inertia starter kit)
๐ ๋ฐ์ดํฐ๋ฒ ์ด์ค ๊ตฌ์กฐ
courses: id, title, description, instructor, thumbnail, durationlessons: id, course_id, title, video_url, orderenrollments: id, user_id, course_id, created_atlesson_completions: id, enrollment_id, lesson_id, completed_atcertificates: id, enrollment_id, issued_at, certificate_code
๐จ UI/UX ํฌ์ธํธ
- ๊ฐ์ข ์นด๋์ ํธ๋ฒ ์ ๊ทธ๋ฆผ์ ํจ๊ณผ + ํ๋ ์ ๋๋ฉ์ด์
- ์งํ๋ฅ ํ๋ก๊ทธ๋ ์ค ๋ฐ ์ ๋๋ฉ์ด์ (0%์์ ํ์ฌ ์งํ๋ฅ ๊น์ง ๋ถ๋๋ฝ๊ฒ ์ฆ๊ฐ)
- ์๋ฃ์ฆ ์์ฑ ์ ์ถํ ๋ชจ๋ฌ ํ์ + ์ปจํํฐ(์์ข ์ด) ์ ๋๋ฉ์ด์
- ์๋ฃ์ฆ ๋ฏธ๋ฆฌ๋ณด๊ธฐ (๋ค์ด๋ก๋ ์ ๋ธ๋ผ์ฐ์ ์์ ํ์ธ)
- ๋ฐ์ํ ๋์์ธ (๋ชจ๋ฐ์ผ์์๋ ํธํ๊ฒ ํ์ต)
์ ์ฝ ์กฐ๊ฑด
- โข Laravel 13.x + Inertia.js + React 19 ๋ชจ๋ ธ๋ฆฌ์ค ๊ตฌ์กฐ ์ฌ์ฉ (๋ณ๋ API ๋ผ์ฐํธ ์์)
- โข npx create laravel ๋๋ Laravel Breeze (React + Inertia) starter kit์ผ๋ก ํ๋ก์ ํธ ์์ฑ
- โข Inertia::render()๋ก Laravel ์ปจํธ๋กค๋ฌ์์ React ์ปดํฌ๋ํธ์ ๋ฐ์ดํฐ ์ ๋ฌ
- โข SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฌ์ฉ (๊ฐํธํ ๋ก์ปฌ ๊ฐ๋ฐ)
- โข Tailwind CSS๋ก ์คํ์ผ๋ง (์ ํธ๋ฆฌํฐ ํด๋์ค ๊ธฐ๋ฐ)
- โข barryvdh/laravel-dompdf ํจํค์ง๋ก PDF ์์ฑ
- โข ์ฌ์ฉ์ ์ธ์ฆ ๊ตฌํ (Laravel Breeze ๊ธฐ๋ณธ ์ ๊ณต)
- โข Eloquent ๊ด๊ณ (Course hasMany Lessons, User hasMany Enrollments ๋ฑ) ์ ์
- โข React ํจ์ํ ์ปดํฌ๋ํธ + Hooks (useState, useEffect) ์ฌ์ฉ
- โข ์ค์๊ฐ ์ฑํ ๊ธฐ๋ฅ ๊ธ์ง (HTTP ๊ธฐ๋ฐ ์์ฒญ๋ง ์ฌ์ฉ)
ํ๋กฌํํธ ๋ ์ํผ
์๋ ํ๋กฌํํธ๋ฅผ ์์๋๋ก AI์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
mkdir course-completion-certificate-automation && cd course-completion-certificate-automation ์ด ํด๋์์ Laravel + Inertia.js + React ํ๋ก์ ํธ๋ฅผ ์์ํฉ๋๋ค. ๋ค์ ๋ช ๋ น์ด๋ก Laravel Breeze (React + Inertia) starter kit์ ์ค์นํ์ธ์: ```bash composer create-project laravel/laravel . composer require laravel/breeze --dev php artisan breeze:install react php artisan migrate npm install npm run dev ``` ์ค์น ํ ๋ค์ ์์ ์ ์ํํ์ธ์: 1. `.env` ํ์ผ์์ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ฅผ SQLite๋ก ์ค์ : ``` DB_CONNECTION=sqlite # DB_HOST, DB_PORT, DB_DATABASE ์ฃผ์ ์ฒ๋ฆฌ ``` 2. `database/database.sqlite` ํ์ผ ์์ฑ: ```bash touch database/database.sqlite ``` 3. ๋ง์ด๊ทธ๋ ์ด์ ํ์ผ 4๊ฐ ์์ฑ: - `create_courses_table`: id, title, description, instructor, thumbnail, duration(๋ถ), timestamps - `create_lessons_table`: id, course_id, title, video_url, order, timestamps - `create_enrollments_table`: id, user_id, course_id, timestamps - `create_lesson_completions_table`: id, enrollment_id, lesson_id, completed_at, timestamps - `create_certificates_table`: id, enrollment_id, issued_at, certificate_code(์ ๋ํฌ), timestamps 4. ๊ฐ ํ ์ด๋ธ์ ์ธ๋ ํค ์ ์ฝ ์กฐ๊ฑด ์ถ๊ฐ: - lessons.course_id โ courses.id (onDelete cascade) - enrollments.user_id โ users.id (onDelete cascade) - enrollments.course_id โ courses.id (onDelete cascade) - lesson_completions.enrollment_id โ enrollments.id (onDelete cascade) - lesson_completions.lesson_id โ lessons.id (onDelete cascade) - certificates.enrollment_id โ enrollments.id (onDelete cascade) 5. `php artisan migrate` ์คํํ์ฌ ํ ์ด๋ธ ์์ฑ ํ์ธ ์๋ฃ๋๋ฉด "Laravel Breeze + Inertia + React ์ค์น ์๋ฃ, ๋ฐ์ดํฐ๋ฒ ์ด์ค ๋ง์ด๊ทธ๋ ์ด์ ์ฑ๊ณต"์ด๋ผ๊ณ ์๋ ค์ฃผ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: Laravel 13.x ํ๋ก์ ํธ๊ฐ ์์ฑ๋๊ณ , Breeze + Inertia + React๊ฐ ์ค์น๋ฉ๋๋ค. SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค๊ฐ ์ค์ ๋๊ณ , courses, lessons, enrollments, lesson_completions, certificates ํ ์ด๋ธ์ด ์์ฑ๋ฉ๋๋ค. ๊ธฐ๋ณธ ์ธ์ฆ ๊ธฐ๋ฅ(๋ก๊ทธ์ธ/ํ์๊ฐ์ )์ด ์๋ํฉ๋๋ค.
์ด์ Eloquent ๋ชจ๋ธ๊ณผ ๊ด๊ณ๋ฅผ ์ค์ ํฉ๋๋ค.
1. ๋ค์ ๋ชจ๋ธ ํ์ผ ์์ฑ:
```bash
php artisan make:model Course
php artisan make:model Lesson
php artisan make:model Enrollment
php artisan make:model LessonCompletion
php artisan make:model Certificate
```
2. `app/Models/Course.php`์ ๊ด๊ณ ์ ์:
```php
public function lessons()
{
return $this->hasMany(Lesson::class)->orderBy('order');
}
public function enrollments()
{
return $this->hasMany(Enrollment::class);
}
```
3. `app/Models/Lesson.php`์ ๊ด๊ณ ์ ์:
```php
public function course()
{
return $this->belongsTo(Course::class);
}
```
4. `app/Models/Enrollment.php`์ ๊ด๊ณ ์ ์:
```php
public function user()
{
return $this->belongsTo(User::class);
}
public function course()
{
return $this->belongsTo(Course::class);
}
public function lessonCompletions()
{
return $this->hasMany(LessonCompletion::class);
}
public function certificate()
{
return $this->hasOne(Certificate::class);
}
// ์งํ๋ฅ ๊ณ์ฐ ๋ฉ์๋
public function getProgressPercentageAttribute()
{
$totalLessons = $this->course->lessons()->count();
if ($totalLessons === 0) return 0;
$completedLessons = $this->lessonCompletions()->count();
return round(($completedLessons / $totalLessons) * 100);
}
public function isCompleted()
{
return $this->progress_percentage === 100;
}
```
5. `app/Models/LessonCompletion.php`์ ๊ด๊ณ ์ ์:
```php
protected $fillable = ['enrollment_id', 'lesson_id', 'completed_at'];
public function enrollment()
{
return $this->belongsTo(Enrollment::class);
}
public function lesson()
{
return $this->belongsTo(Lesson::class);
}
```
6. `app/Models/Certificate.php`์ ๊ด๊ณ ์ ์:
```php
protected $fillable = ['enrollment_id', 'issued_at', 'certificate_code'];
public function enrollment()
{
return $this->belongsTo(Enrollment::class);
}
```
7. Seeder ์์ฑํ์ฌ ์ํ ๋ฐ์ดํฐ ์ถ๊ฐ:
```bash
php artisan make:seeder CourseSeeder
```
`database/seeders/CourseSeeder.php`์ ๋ค์ ๋ด์ฉ ์์ฑ:
```php
use App\Models\Course;
use App\Models\Lesson;
public function run()
{
$course1 = Course::create([
'title' => 'Laravel ๋ง์คํฐ ํด๋์ค',
'description' => 'Laravel 13.x๋ก ํ์คํ ์น ๊ฐ๋ฐ ์์ ์ ๋ณต',
'instructor' => '๊น๊ฐ๋ฐ',
'thumbnail' => 'https://via.placeholder.com/400x250?text=Laravel+Course',
'duration' => 180,
]);
Lesson::create(['course_id' => $course1->id, 'title' => '1๊ฐ: Laravel ์๊ฐ', 'video_url' => 'https://example.com/video1', 'order' => 1]);
Lesson::create(['course_id' => $course1->id, 'title' => '2๊ฐ: ๋ผ์ฐํ
๊ธฐ์ด', 'video_url' => 'https://example.com/video2', 'order' => 2]);
Lesson::create(['course_id' => $course1->id, 'title' => '3๊ฐ: Eloquent ORM', 'video_url' => 'https://example.com/video3', 'order' => 3]);
Lesson::create(['course_id' => $course1->id, 'title' => '4๊ฐ: Inertia.js ํตํฉ', 'video_url' => 'https://example.com/video4', 'order' => 4]);
$course2 = Course::create([
'title' => 'React 19 ์๋ฒฝ ๊ฐ์ด๋',
'description' => '์ต์ React ๊ธฐ๋ฅ๊ณผ Hooks ๋ง์คํฐํ๊ธฐ',
'instructor' => '๋ฐ๋ฆฌ์กํธ',
'thumbnail' => 'https://via.placeholder.com/400x250?text=React+Course',
'duration' => 150,
]);
Lesson::create(['course_id' => $course2->id, 'title' => '1๊ฐ: React ๊ธฐ์ด', 'video_url' => 'https://example.com/video5', 'order' => 1]);
Lesson::create(['course_id' => $course2->id, 'title' => '2๊ฐ: useState Hook', 'video_url' => 'https://example.com/video6', 'order' => 2]);
Lesson::create(['course_id' => $course2->id, 'title' => '3๊ฐ: useEffect Hook', 'video_url' => 'https://example.com/video7', 'order' => 3]);
}
```
8. DatabaseSeeder์์ CourseSeeder ํธ์ถ:
```php
public function run()
{
$this->call(CourseSeeder::class);
}
```
9. ์๋ ์คํ:
```bash
php artisan db:seed
```
์๋ฃ๋๋ฉด "๋ชจ๋ธ ๊ด๊ณ ์ค์ ์๋ฃ, ์ํ ๋ฐ์ดํฐ ์ถ๊ฐ๋จ"์ด๋ผ๊ณ ์๋ ค์ฃผ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: Course, Lesson, Enrollment, LessonCompletion, Certificate ๋ชจ๋ธ์ด ์์ฑ๋๊ณ , Eloquent ๊ด๊ณ๊ฐ ์ ์๋ฉ๋๋ค. CourseSeeder๋ก ์ํ ๊ฐ์ข 2๊ฐ์ ๊ฐ์ ์์ 7๊ฐ๊ฐ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ถ๊ฐ๋ฉ๋๋ค. ์งํ๋ฅ ๊ณ์ฐ ๋ฉ์๋๊ฐ Enrollment ๋ชจ๋ธ์ ๊ตฌํ๋ฉ๋๋ค.
์ด์ ๊ฐ์ข ๋ชฉ๋ก ํ์ด์ง์ ์์ธ ํ์ด์ง๋ฅผ ๋ง๋ญ๋๋ค.
1. CourseController ์์ฑ:
```bash
php artisan make:controller CourseController
```
2. `app/Http/Controllers/CourseController.php`์ ๋ค์ ๋ฉ์๋ ์์ฑ:
```php
use App\Models\Course;
use App\Models\Enrollment;
use Inertia\Inertia;
public function index()
{
$courses = Course::withCount('lessons')->get();
$enrolledCourseIds = auth()->user()->enrollments()->pluck('course_id')->toArray();
return Inertia::render('Courses/Index', [
'courses' => $courses,
'enrolledCourseIds' => $enrolledCourseIds,
]);
}
public function show(Course $course)
{
$course->load('lessons');
$enrollment = auth()->user()->enrollments()->where('course_id', $course->id)->first();
$completedLessonIds = [];
$progress = 0;
if ($enrollment) {
$completedLessonIds = $enrollment->lessonCompletions()->pluck('lesson_id')->toArray();
$progress = $enrollment->progress_percentage;
}
return Inertia::render('Courses/Show', [
'course' => $course,
'enrollment' => $enrollment,
'completedLessonIds' => $completedLessonIds,
'progress' => $progress,
]);
}
public function enroll(Course $course)
{
$enrollment = auth()->user()->enrollments()->firstOrCreate([
'course_id' => $course->id,
]);
return redirect()->route('courses.show', $course);
}
```
3. `routes/web.php`์ ๋ผ์ฐํธ ์ถ๊ฐ:
```php
use App\Http\Controllers\CourseController;
Route::middleware('auth')->group(function () {
Route::get('/courses', [CourseController::class, 'index'])->name('courses.index');
Route::get('/courses/{course}', [CourseController::class, 'show'])->name('courses.show');
Route::post('/courses/{course}/enroll', [CourseController::class, 'enroll'])->name('courses.enroll');
});
```
4. React ์ปดํฌ๋ํธ ์์ฑ: `resources/js/Pages/Courses/Index.jsx`
```jsx
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link } from '@inertiajs/react';
export default function Index({ auth, courses, enrolledCourseIds }) {
return (
<AuthenticatedLayout user={auth.user}>
<Head title="๊ฐ์ข ๋ชฉ๋ก" />
<div className="py-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold text-gray-900 mb-8">์๊ฐ ๊ฐ๋ฅํ ๊ฐ์ข</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{courses.map((course) => (
<Link
key={course.id}
href={route('courses.show', course.id)}
className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition-shadow duration-300 transform hover:scale-105"
>
<img
src={course.thumbnail}
alt={course.title}
className="w-full h-48 object-cover"
/>
<div className="p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-2">
{course.title}
</h2>
<p className="text-gray-600 text-sm mb-4">
๊ฐ์ฌ: {course.instructor}
</p>
<div className="flex justify-between items-center">
<span className="text-gray-500 text-sm">
{course.lessons_count}๊ฐ ๊ฐ์
</span>
<span className="text-gray-500 text-sm">
{course.duration}๋ถ
</span>
</div>
{enrolledCourseIds.includes(course.id) && (
<div className="mt-4">
<span className="inline-block bg-green-100 text-green-800 text-xs px-3 py-1 rounded-full">
์๊ฐ ์ค
</span>
</div>
)}
</div>
</Link>
))}
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
```
5. React ์ปดํฌ๋ํธ ์์ฑ: `resources/js/Pages/Courses/Show.jsx`
```jsx
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, router } from '@inertiajs/react';
export default function Show({ auth, course, enrollment, completedLessonIds, progress }) {
const handleEnroll = () => {
router.post(route('courses.enroll', course.id));
};
return (
<AuthenticatedLayout user={auth.user}>
<Head title={course.title} />
<div className="py-12">
<div className="max-w-4xl mx-auto sm:px-6 lg:px-8">
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<img
src={course.thumbnail}
alt={course.title}
className="w-full h-64 object-cover"
/>
<div className="p-8">
<h1 className="text-3xl font-bold text-gray-900 mb-4">
{course.title}
</h1>
<p className="text-gray-700 mb-6">{course.description}</p>
<div className="flex items-center justify-between mb-8">
<div>
<p className="text-gray-600">๊ฐ์ฌ: {course.instructor}</p>
<p className="text-gray-600">
์ด {course.lessons.length}๊ฐ ๊ฐ์ โข {course.duration}๋ถ
</p>
</div>
{!enrollment && (
<button
onClick={handleEnroll}
className="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors"
>
์๊ฐ ์์ํ๊ธฐ
</button>
)}
</div>
{enrollment && (
<div className="mb-8">
<div className="flex justify-between items-center mb-2">
<span className="text-gray-700 font-medium">ํ์ต ์งํ๋ฅ </span>
<span className="text-blue-600 font-bold">{progress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-4">
<div
className="bg-blue-600 h-4 rounded-full transition-all duration-500"
style={{ width: `${progress}%` }}
></div>
</div>
</div>
)}
<h2 className="text-2xl font-semibold text-gray-900 mb-4">์ปค๋ฆฌํ๋ผ</h2>
<div className="space-y-3">
{course.lessons.map((lesson, index) => (
<div
key={lesson.id}
className={`p-4 rounded-lg border ${
completedLessonIds.includes(lesson.id)
? 'bg-green-50 border-green-300'
: 'bg-gray-50 border-gray-200'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
{completedLessonIds.includes(lesson.id) ? (
<svg className="w-6 h-6 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
) : (
<div className="w-6 h-6 rounded-full border-2 border-gray-400"></div>
)}
<span className="font-medium text-gray-900">{lesson.title}</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
```
6. ๋ค๋น๊ฒ์ด์
์ "๊ฐ์ข ๋ชฉ๋ก" ๋งํฌ ์ถ๊ฐ: `resources/js/Layouts/AuthenticatedLayout.jsx`์ NavLink ์น์
์ ์ถ๊ฐ:
```jsx
<NavLink href={route('courses.index')} active={route().current('courses.index')}>
๊ฐ์ข ๋ชฉ๋ก
</NavLink>
```
์๋ฃ๋๋ฉด "๊ฐ์ข ๋ชฉ๋ก ๋ฐ ์์ธ ํ์ด์ง ์์ฑ"์ด๋ผ๊ณ ์๋ ค์ฃผ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: CourseController๊ฐ ์์ฑ๋๊ณ , ๊ฐ์ข ๋ชฉ๋ก ํ์ด์ง์ ์์ธ ํ์ด์ง๊ฐ Inertia.js๋ก ๋ ๋๋ง๋ฉ๋๋ค. ์ฌ์ฉ์๋ ๊ฐ์ข ์นด๋๋ฅผ ํด๋ฆญํ์ฌ ์์ธ ์ ๋ณด๋ฅผ ํ์ธํ๊ณ , '์๊ฐ ์์ํ๊ธฐ' ๋ฒํผ์ผ๋ก ์๊ฐ ๋ฑ๋กํ ์ ์์ต๋๋ค. ๋ฑ๋ก ํ ์ปค๋ฆฌํ๋ผ์ด ์ฒดํฌ๋ฆฌ์คํธ ํํ๋ก ํ์๋๋ฉฐ, ์งํ๋ฅ ํ๋ก๊ทธ๋ ์ค ๋ฐ๊ฐ ๋ํ๋ฉ๋๋ค.
์ด์ ๊ฐ์ ์์ ์์ฒญ ์๋ฃ ์ฒดํฌ ๊ธฐ๋ฅ์ ๊ตฌํํฉ๋๋ค.
1. LessonController ์์ฑ:
```bash
php artisan make:controller LessonController
```
2. `app/Http/Controllers/LessonController.php`์ ๋ฉ์๋ ์์ฑ:
```php
use App\Models\Lesson;
use App\Models\Enrollment;
use App\Models\LessonCompletion;
use Illuminate\Http\Request;
public function toggleCompletion(Request $request, Lesson $lesson)
{
$enrollment = auth()->user()->enrollments()
->where('course_id', $lesson->course_id)
->firstOrFail();
$completion = LessonCompletion::where('enrollment_id', $enrollment->id)
->where('lesson_id', $lesson->id)
->first();
if ($completion) {
// ์ด๋ฏธ ์๋ฃ๋ ๊ฒฝ์ฐ ์ญ์ (์ฒดํฌ ํด์ )
$completion->delete();
$message = '์๋ฃ ํ์๊ฐ ํด์ ๋์์ต๋๋ค.';
} else {
// ์๋ฃ ํ์
LessonCompletion::create([
'enrollment_id' => $enrollment->id,
'lesson_id' => $lesson->id,
'completed_at' => now(),
]);
$message = '๊ฐ์ ์์ฒญ์ ์๋ฃํ์ต๋๋ค!';
}
// ์งํ๋ฅ ์ฌ๊ณ์ฐ
$progress = $enrollment->fresh()->progress_percentage;
return back()->with([
'message' => $message,
'progress' => $progress,
]);
}
```
3. `routes/web.php`์ ๋ผ์ฐํธ ์ถ๊ฐ:
```php
use App\Http\Controllers\LessonController;
Route::middleware('auth')->group(function () {
Route::post('/lessons/{lesson}/toggle-completion', [LessonController::class, 'toggleCompletion'])->name('lessons.toggle-completion');
});
```
4. `resources/js/Pages/Courses/Show.jsx` ์์ (๊ฐ์ ํญ๋ชฉ์ ์ฒดํฌ๋ฐ์ค ์ถ๊ฐ):
```jsx
import { Head, router, usePage } from '@inertiajs/react';
import { useEffect } from 'react';
export default function Show({ auth, course, enrollment, completedLessonIds, progress }) {
const { flash } = usePage().props;
useEffect(() => {
if (flash.message) {
alert(flash.message);
}
}, [flash]);
const handleEnroll = () => {
router.post(route('courses.enroll', course.id));
};
const handleToggleCompletion = (lessonId) => {
router.post(route('lessons.toggle-completion', lessonId));
};
return (
<AuthenticatedLayout user={auth.user}>
<Head title={course.title} />
<div className="py-12">
<div className="max-w-4xl mx-auto sm:px-6 lg:px-8">
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<img
src={course.thumbnail}
alt={course.title}
className="w-full h-64 object-cover"
/>
<div className="p-8">
<h1 className="text-3xl font-bold text-gray-900 mb-4">
{course.title}
</h1>
<p className="text-gray-700 mb-6">{course.description}</p>
<div className="flex items-center justify-between mb-8">
<div>
<p className="text-gray-600">๊ฐ์ฌ: {course.instructor}</p>
<p className="text-gray-600">
์ด {course.lessons.length}๊ฐ ๊ฐ์ โข {course.duration}๋ถ
</p>
</div>
{!enrollment && (
<button
onClick={handleEnroll}
className="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors"
>
์๊ฐ ์์ํ๊ธฐ
</button>
)}
</div>
{enrollment && (
<div className="mb-8">
<div className="flex justify-between items-center mb-2">
<span className="text-gray-700 font-medium">ํ์ต ์งํ๋ฅ </span>
<span className="text-blue-600 font-bold">{progress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-4">
<div
className="bg-blue-600 h-4 rounded-full transition-all duration-500"
style={{ width: `${progress}%` }}
></div>
</div>
</div>
)}
<h2 className="text-2xl font-semibold text-gray-900 mb-4">์ปค๋ฆฌํ๋ผ</h2>
<div className="space-y-3">
{course.lessons.map((lesson, index) => (
<div
key={lesson.id}
className={`p-4 rounded-lg border ${
completedLessonIds.includes(lesson.id)
? 'bg-green-50 border-green-300'
: 'bg-gray-50 border-gray-200'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<button
onClick={() => handleToggleCompletion(lesson.id)}
disabled={!enrollment}
className="focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
{completedLessonIds.includes(lesson.id) ? (
<svg className="w-6 h-6 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
) : (
<div className="w-6 h-6 rounded-full border-2 border-gray-400 hover:border-blue-500 transition-colors"></div>
)}
</button>
<span className="font-medium text-gray-900">{lesson.title}</span>
</div>
{enrollment && (
<a
href={lesson.video_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
>
์์ ๋ณด๊ธฐ โ
</a>
)}
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
```
์๋ฃ๋๋ฉด "๊ฐ์ ์์ฒญ ์๋ฃ ์ฒดํฌ ๊ธฐ๋ฅ ๊ตฌํ ์๋ฃ"๋ผ๊ณ ์๋ ค์ฃผ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: LessonController๊ฐ ์์ฑ๋๊ณ , ๊ฐ์ ํญ๋ชฉ ์ ์ฒดํฌ๋ฐ์ค๋ฅผ ํด๋ฆญํ๋ฉด ์๋ฃ ํ์๊ฐ ํ ๊ธ๋ฉ๋๋ค. ์๋ฃ๋ ๊ฐ์๋ ์ด๋ก์ ๋ฐฐ๊ฒฝ์ผ๋ก ๋ณ๊ฒฝ๋๊ณ , ์งํ๋ฅ ํ๋ก๊ทธ๋ ์ค ๋ฐ๊ฐ ์ค์๊ฐ์ผ๋ก ์ ๋ฐ์ดํธ๋ฉ๋๋ค. ์ฌ์ฉ์๋ '์์ ๋ณด๊ธฐ' ๋งํฌ๋ก ๊ฐ์ ์์์ ์ ๊ทผํ ์ ์์ต๋๋ค.
์ด์ ์๋ฃ์ฆ ์๋ ์์ฑ ๋ฐ PDF ๋ค์ด๋ก๋ ๊ธฐ๋ฅ์ ๊ตฌํํฉ๋๋ค.
1. DomPDF ํจํค์ง ์ค์น:
```bash
composer require barryvdh/laravel-dompdf
```
2. CertificateController ์์ฑ:
```bash
php artisan make:controller CertificateController
```
3. `app/Http/Controllers/CertificateController.php`์ ๋ฉ์๋ ์์ฑ:
```php
use App\Models\Enrollment;
use App\Models\Certificate;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Support\Str;
public function generate(Enrollment $enrollment)
{
// ๊ถํ ํ์ธ
if ($enrollment->user_id !== auth()->id()) {
abort(403, '๊ถํ์ด ์์ต๋๋ค.');
}
// 100% ์๋ฃ ํ์ธ
if (!$enrollment->isCompleted()) {
return back()->with('error', '๋ชจ๋ ๊ฐ์๋ฅผ ์๋ฃํด์ผ ์๋ฃ์ฆ์ ๋ฐ๊ธ๋ฐ์ ์ ์์ต๋๋ค.');
}
// ์ด๋ฏธ ๋ฐ๊ธ๋ ์๋ฃ์ฆ์ด ์๋์ง ํ์ธ
$certificate = $enrollment->certificate;
if (!$certificate) {
// ์๋ฃ์ฆ ์์ฑ
$certificate = Certificate::create([
'enrollment_id' => $enrollment->id,
'issued_at' => now(),
'certificate_code' => strtoupper(Str::random(12)),
]);
}
return redirect()->route('certificates.show', $certificate);
}
public function show(Certificate $certificate)
{
// ๊ถํ ํ์ธ
if ($certificate->enrollment->user_id !== auth()->id()) {
abort(403, '๊ถํ์ด ์์ต๋๋ค.');
}
$certificate->load('enrollment.user', 'enrollment.course');
return Inertia::render('Certificates/Show', [
'certificate' => $certificate,
]);
}
public function download(Certificate $certificate)
{
// ๊ถํ ํ์ธ
if ($certificate->enrollment->user_id !== auth()->id()) {
abort(403, '๊ถํ์ด ์์ต๋๋ค.');
}
$certificate->load('enrollment.user', 'enrollment.course');
$pdf = Pdf::loadView('certificates.pdf', [
'certificate' => $certificate,
'userName' => $certificate->enrollment->user->name,
'courseName' => $certificate->enrollment->course->title,
'issuedDate' => $certificate->issued_at->format('Y๋
m์ d์ผ'),
'certificateCode' => $certificate->certificate_code,
]);
return $pdf->download('์๋ฃ์ฆ_' . $certificate->enrollment->course->title . '.pdf');
}
```
4. `routes/web.php`์ ๋ผ์ฐํธ ์ถ๊ฐ:
```php
use App\Http\Controllers\CertificateController;
Route::middleware('auth')->group(function () {
Route::post('/enrollments/{enrollment}/generate-certificate', [CertificateController::class, 'generate'])->name('certificates.generate');
Route::get('/certificates/{certificate}', [CertificateController::class, 'show'])->name('certificates.show');
Route::get('/certificates/{certificate}/download', [CertificateController::class, 'download'])->name('certificates.download');
});
```
5. PDF ํ
ํ๋ฆฟ ์์ฑ: `resources/views/certificates/pdf.blade.php`
```blade
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>์๋ฃ์ฆ</title>
<style>
@page {
margin: 0;
}
body {
margin: 0;
padding: 60px;
font-family: 'DejaVu Sans', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #333;
}
.certificate {
background: white;
padding: 80px 60px;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
text-align: center;
}
.title {
font-size: 48px;
font-weight: bold;
color: #667eea;
margin-bottom: 40px;
letter-spacing: 2px;
}
.subtitle {
font-size: 24px;
color: #666;
margin-bottom: 60px;
}
.recipient {
font-size: 36px;
font-weight: bold;
color: #333;
margin-bottom: 20px;
border-bottom: 3px solid #667eea;
display: inline-block;
padding-bottom: 10px;
}
.course-name {
font-size: 28px;
color: #764ba2;
margin: 40px 0;
font-weight: bold;
}
.completion-text {
font-size: 18px;
color: #666;
margin-bottom: 60px;
line-height: 1.8;
}
.footer {
margin-top: 80px;
padding-top: 40px;
border-top: 2px solid #eee;
}
.date {
font-size: 16px;
color: #999;
margin-bottom: 20px;
}
.code {
font-size: 14px;
color: #aaa;
font-family: monospace;
}
</style>
</head>
<body>
<div class="certificate">
<div class="title">์๋ฃ์ฆ</div>
<div class="subtitle">Certificate of Completion</div>
<div class="recipient">{{ $userName }}</div>
<div class="completion-text">
์ ์ฌ๋์ ๋ค์ ๊ฐ์ข๋ฅผ ์ฑ์คํ ์๊ฐํ๊ณ <br>
๋ชจ๋ ๊ณผ์ ์ ์ด์ํ์์์ ์ฆ๋ช
ํฉ๋๋ค.
</div>
<div class="course-name">{{ $courseName }}</div>
<div class="footer">
<div class="date">๋ฐ๊ธ์ผ: {{ $issuedDate }}</div>
<div class="code">์ธ์ฆ๋ฒํธ: {{ $certificateCode }}</div>
</div>
</div>
</body>
</html>
```
6. `resources/js/Pages/Courses/Show.jsx` ์์ (์๋ฃ์ฆ ๋ฐ๊ธ ๋ฒํผ ์ถ๊ฐ):
- ์งํ๋ฅ 100% ๋๋ฌ ์ ์ถํ ๋ฉ์์ง์ ์๋ฃ์ฆ ๋ฐ๊ธ ๋ฒํผ ํ์:
```jsx
{enrollment && progress === 100 && (
<div className="mb-8 bg-gradient-to-r from-green-50 to-blue-50 border-2 border-green-300 rounded-lg p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-2xl font-bold text-green-700 mb-2">๐ ์ถํํฉ๋๋ค!</h3>
<p className="text-gray-700">๋ชจ๋ ๊ฐ์๋ฅผ ์๋ฃํ์
จ์ต๋๋ค. ์๋ฃ์ฆ์ ๋ฐ๊ธ๋ฐ์ผ์ธ์.</p>
</div>
<button
onClick={() => router.post(route('certificates.generate', enrollment.id))}
className="bg-gradient-to-r from-green-500 to-blue-500 text-white px-6 py-3 rounded-lg hover:from-green-600 hover:to-blue-600 transition-all transform hover:scale-105 shadow-lg"
>
์๋ฃ์ฆ ๋ฐ๊ธ
</button>
</div>
</div>
)}
```
7. React ์ปดํฌ๋ํธ ์์ฑ: `resources/js/Pages/Certificates/Show.jsx`
```jsx
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link } from '@inertiajs/react';
export default function Show({ auth, certificate }) {
return (
<AuthenticatedLayout user={auth.user}>
<Head title="์๋ฃ์ฆ" />
<div className="py-12">
<div className="max-w-4xl mx-auto sm:px-6 lg:px-8">
<div className="bg-gradient-to-br from-purple-600 to-blue-600 rounded-lg shadow-2xl overflow-hidden">
<div className="bg-white m-8 p-12 rounded-lg">
<div className="text-center">
<h1 className="text-5xl font-bold text-purple-600 mb-8">์๋ฃ์ฆ</h1>
<p className="text-xl text-gray-600 mb-12">Certificate of Completion</p>
<div className="text-4xl font-bold text-gray-900 mb-4 border-b-4 border-purple-600 inline-block pb-2">
{certificate.enrollment.user.name}
</div>
<p className="text-lg text-gray-700 my-12 leading-relaxed">
์ ์ฌ๋์ ๋ค์ ๊ฐ์ข๋ฅผ ์ฑ์คํ ์๊ฐํ๊ณ <br />
๋ชจ๋ ๊ณผ์ ์ ์ด์ํ์์์ ์ฆ๋ช
ํฉ๋๋ค.
</p>
<div className="text-3xl font-bold text-blue-700 my-12">
{certificate.enrollment.course.title}
</div>
<div className="mt-16 pt-8 border-t-2 border-gray-200">
<p className="text-gray-600 mb-4">
๋ฐ๊ธ์ผ: {new Date(certificate.issued_at).toLocaleDateString('ko-KR')}
</p>
<p className="text-gray-500 text-sm font-mono">
์ธ์ฆ๋ฒํธ: {certificate.certificate_code}
</p>
</div>
</div>
</div>
</div>
<div className="mt-8 flex justify-center space-x-4">
<a
href={route('certificates.download', certificate.id)}
className="bg-green-600 text-white px-8 py-4 rounded-lg hover:bg-green-700 transition-colors shadow-lg text-lg font-semibold"
>
๐ฅ PDF ๋ค์ด๋ก๋
</a>
<Link
href={route('courses.index')}
className="bg-gray-600 text-white px-8 py-4 rounded-lg hover:bg-gray-700 transition-colors shadow-lg text-lg font-semibold"
>
๊ฐ์ข ๋ชฉ๋ก์ผ๋ก
</Link>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
```
์๋ฃ๋๋ฉด "์๋ฃ์ฆ ์๋ ์์ฑ ๋ฐ PDF ๋ค์ด๋ก๋ ๊ธฐ๋ฅ ์์ฑ"์ด๋ผ๊ณ ์๋ ค์ฃผ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: CertificateController๊ฐ ์์ฑ๋๊ณ , 100% ์๋ฃ ์ ์๋ฃ์ฆ ๋ฐ๊ธ ๋ฒํผ์ด ๋ํ๋ฉ๋๋ค. ๋ฒํผ ํด๋ฆญ ์ ์๋ฃ์ฆ์ด ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ ์ฅ๋๊ณ , ์๋ฃ์ฆ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ํ์ด์ง๋ก ์ด๋ํฉ๋๋ค. PDF ๋ค์ด๋ก๋ ๋ฒํผ์ ๋๋ฅด๋ฉด DomPDF๋ก ์์ฑ๋ ์๋ฃ์ฆ PDF ํ์ผ์ด ๋ค์ด๋ก๋๋ฉ๋๋ค. ์๋ฃ์ฆ์๋ ์๊ฐ์ ์ด๋ฆ, ๊ฐ์ข๋ช , ๋ฐ๊ธ์ผ, ์ธ์ฆ๋ฒํธ๊ฐ ํ์๋ฉ๋๋ค.
๋ง์ง๋ง์ผ๋ก ์๋ฃ ์ด๋ ฅ ํ์ด์ง๋ฅผ ๋ง๋ค์ด์ ์ฌ์ฉ์๊ฐ ์์ ์ด ์๋ฃํ ๊ฐ์ข ๋ชฉ๋ก๊ณผ ์๋ฃ์ฆ์ ํ๋์ ๋ณผ ์ ์๋๋ก ํฉ๋๋ค.
1. `app/Http/Controllers/CertificateController.php`์ index ๋ฉ์๋ ์ถ๊ฐ:
```php
public function index()
{
$certificates = auth()->user()->enrollments()
->whereHas('certificate')
->with(['course', 'certificate'])
->get()
->map(function ($enrollment) {
return [
'id' => $enrollment->certificate->id,
'course_title' => $enrollment->course->title,
'course_thumbnail' => $enrollment->course->thumbnail,
'instructor' => $enrollment->course->instructor,
'issued_at' => $enrollment->certificate->issued_at->format('Y๋
m์ d์ผ'),
'certificate_code' => $enrollment->certificate->certificate_code,
];
});
return Inertia::render('Certificates/Index', [
'certificates' => $certificates,
]);
}
```
2. `routes/web.php`์ ๋ผ์ฐํธ ์ถ๊ฐ:
```php
Route::middleware('auth')->group(function () {
Route::get('/my-certificates', [CertificateController::class, 'index'])->name('certificates.index');
});
```
3. React ์ปดํฌ๋ํธ ์์ฑ: `resources/js/Pages/Certificates/Index.jsx`
```jsx
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link } from '@inertiajs/react';
export default function Index({ auth, certificates }) {
return (
<AuthenticatedLayout user={auth.user}>
<Head title="์๋ฃ ์ด๋ ฅ" />
<div className="py-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">๋ด ์๋ฃ์ฆ</h1>
<p className="text-gray-600">์๋ฃํ ๊ฐ์ข์ ์๋ฃ์ฆ์ ํ์ธํ๊ณ ๋ค์ด๋ก๋ํ ์ ์์ต๋๋ค.</p>
</div>
{certificates.length === 0 ? (
<div className="bg-white rounded-lg shadow-md p-12 text-center">
<svg className="mx-auto h-24 w-24 text-gray-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 className="text-xl font-semibold text-gray-900 mb-2">์์ง ์๋ฃํ ๊ฐ์ข๊ฐ ์์ต๋๋ค</h3>
<p className="text-gray-600 mb-6">๊ฐ์ข๋ฅผ ์๋ฃํ๊ณ ์๋ฃ์ฆ์ ๋ฐ์๋ณด์ธ์!</p>
<Link
href={route('courses.index')}
className="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors"
>
๊ฐ์ข ๋๋ฌ๋ณด๊ธฐ
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{certificates.map((cert) => (
<div
key={cert.id}
className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition-shadow duration-300"
>
<div className="relative">
<img
src={cert.course_thumbnail}
alt={cert.course_title}
className="w-full h-48 object-cover"
/>
<div className="absolute top-4 right-4 bg-green-500 text-white px-3 py-1 rounded-full text-sm font-semibold">
โ ์๋ฃ ์๋ฃ
</div>
</div>
<div className="p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-2">
{cert.course_title}
</h2>
<p className="text-gray-600 text-sm mb-4">
๊ฐ์ฌ: {cert.instructor}
</p>
<div className="border-t border-gray-200 pt-4 mb-4">
<p className="text-gray-600 text-sm mb-1">
๋ฐ๊ธ์ผ: {cert.issued_at}
</p>
<p className="text-gray-500 text-xs font-mono">
์ธ์ฆ๋ฒํธ: {cert.certificate_code}
</p>
</div>
<div className="flex space-x-2">
<Link
href={route('certificates.show', cert.id)}
className="flex-1 bg-blue-600 text-white text-center px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
>
๋ฏธ๋ฆฌ๋ณด๊ธฐ
</Link>
<a
href={route('certificates.download', cert.id)}
className="flex-1 bg-green-600 text-white text-center px-4 py-2 rounded-lg hover:bg-green-700 transition-colors text-sm font-medium"
>
PDF ๋ค์ด
</a>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</AuthenticatedLayout>
);
}
```
4. ๋ค๋น๊ฒ์ด์
์ "๋ด ์๋ฃ์ฆ" ๋งํฌ ์ถ๊ฐ: `resources/js/Layouts/AuthenticatedLayout.jsx`์ NavLink ์น์
์ ์ถ๊ฐ:
```jsx
<NavLink href={route('certificates.index')} active={route().current('certificates.index')}>
๋ด ์๋ฃ์ฆ
</NavLink>
```
5. ํ๋ก์ ํธ ์ต์ข
ํ
์คํธ:
- ํ์๊ฐ์
ํ ๋ก๊ทธ์ธ
- ๊ฐ์ข ๋ชฉ๋ก์์ ๊ฐ์ข ํด๋ฆญ
- "์๊ฐ ์์ํ๊ธฐ" ๋ฒํผ์ผ๋ก ์๊ฐ ๋ฑ๋ก
- ๊ฐ ๊ฐ์ ์ ์ฒดํฌ๋ฐ์ค๋ฅผ ํด๋ฆญํ์ฌ ์๋ฃ ํ์
- ์งํ๋ฅ ์ด 100%์ ๋๋ฌํ๋ฉด "์๋ฃ์ฆ ๋ฐ๊ธ" ๋ฒํผ ํด๋ฆญ
- ์๋ฃ์ฆ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ํ์ธ
- PDF ๋ค์ด๋ก๋ ๋ฒํผ์ผ๋ก ์๋ฃ์ฆ ๋ค์ด๋ก๋
- "๋ด ์๋ฃ์ฆ" ๋ฉ๋ด์์ ์๋ฃ ์ด๋ ฅ ํ์ธ
6. (์ ํ) README.md ์์ฑ:
```markdown
# ์จ๋ผ์ธ ๊ฐ์ข ์๋ฃ์ฆ ์๋ ๋ฐ๊ธ ์์คํ
Laravel 13.x + Inertia.js + React 19๋ก ๋ง๋ ์จ๋ผ์ธ ๊ต์ก ํ๋ซํผ์
๋๋ค.
## ์ฃผ์ ๊ธฐ๋ฅ
- ๊ฐ์ข ๋ชฉ๋ก ๋ฐ ์์ธ ์ ๋ณด ํ์ธ
- ๊ฐ์ ์์ ์์ฒญ ์ฒดํฌ๋ฆฌ์คํธ
- ์ค์๊ฐ ์งํ๋ฅ ์ถ์
- 100% ์๋ฃ ์ ์๋ฃ์ฆ ์๋ ๋ฐ๊ธ
- PDF ์๋ฃ์ฆ ๋ค์ด๋ก๋
- ์๋ฃ ์ด๋ ฅ ๊ด๋ฆฌ
## ์ค์น ๋ฐฉ๋ฒ
```bash
composer install
npm install
cp .env.example .env
touch database/database.sqlite
php artisan key:generate
php artisan migrate
php artisan db:seed
npm run dev
php artisan serve
```
## ์ฌ์ฉ ๊ธฐ์
- Laravel 13.x
- Inertia.js
- React 19
- Tailwind CSS
- DomPDF
```
์๋ฃ๋๋ฉด "ํ๋ก์ ํธ ์์ฑ! ์๋ฃ ์ด๋ ฅ ํ์ด์ง๊น์ง ๋ชจ๋ ๊ธฐ๋ฅ ๊ตฌํ ์๋ฃ"๋ผ๊ณ ์๋ ค์ฃผ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: ์๋ฃ ์ด๋ ฅ ํ์ด์ง๊ฐ ์์ฑ๋์ด ์ฌ์ฉ์๊ฐ ์์ ์ด ์๋ฃํ ๋ชจ๋ ๊ฐ์ข์ ์๋ฃ์ฆ์ ์นด๋ ํํ๋ก ํ์ธํ ์ ์์ต๋๋ค. ๊ฐ ์นด๋์์ ์๋ฃ์ฆ ๋ฏธ๋ฆฌ๋ณด๊ธฐ์ PDF ๋ค์ด๋ก๋๊ฐ ๊ฐ๋ฅํฉ๋๋ค. ์์ง ์๋ฃํ ๊ฐ์ข๊ฐ ์๋ ๊ฒฝ์ฐ ์๋ด ๋ฉ์์ง์ ๊ฐ์ข ๋ชฉ๋ก ๋งํฌ๊ฐ ํ์๋ฉ๋๋ค. ๋ชจ๋ ๊ธฐ๋ฅ์ด ์ ์ ์๋ํ๋ฉฐ, ํ๋ก์ ํธ๊ฐ ์์ฑ๋ฉ๋๋ค.
์ ์ถ๋ ์ํ (0)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!