๐ŸŽ“ ์˜จ๋ผ์ธ ์›Œํฌ์ˆ ๋“ฑ๋ก & ์ˆ˜๋ฃŒ์ฆ ๋ฐœ๊ธ‰ ํ”Œ๋žซํผ - ๋ฐ”์ด๋ธŒ์ฝ”๋”ฉ ๋ ˆ์‹œํ”ผ
์ค‘๊ธ‰ PHP + JavaScript Laravel + React + Inertia.js AI: Claude

๐ŸŽ“ ์˜จ๋ผ์ธ ์›Œํฌ์ˆ ๋“ฑ๋ก & ์ˆ˜๋ฃŒ์ฆ ๋ฐœ๊ธ‰ ํ”Œ๋žซํผ

๋ฆฌํฌ์ง€ํ† ๋ฆฌ๋ช… workshop-enrollment-certificate-platform
์กฐํšŒ์ˆ˜ 69 0๊ฑด ์ œ์ถœ

๋ฌธ์ œ ์„ค๋ช…

๐ŸŽ“ ์˜จ๋ผ์ธ ์›Œํฌ์ˆ ๋“ฑ๋ก & ์ˆ˜๋ฃŒ์ฆ ๋ฐœ๊ธ‰ ํ”Œ๋žซํผ

์‹ค์ œ ๊ต์œก ๊ธฐ๊ด€์ด๋‚˜ ํ”„๋ฆฌ๋žœ์„œ ๊ฐ•์‚ฌ๊ฐ€ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ์›Œํฌ์ˆ ๋“ฑ๋ก ์‹œ์Šคํ…œ์„ ๋งŒ๋“ญ๋‹ˆ๋‹ค. ์ฐธ๊ฐ€์ž๋Š” ์›Œํฌ์ˆ์„ ๋‘˜๋Ÿฌ๋ณด๊ณ , ๊ด€์‹ฌ ์žˆ๋Š” ์„ธ์…˜์— ๋“ฑ๋กํ•˜๋ฉฐ, ์ถœ์„ ์ฒดํฌ ํ›„ ์ž๋™์œผ๋กœ ์ˆ˜๋ฃŒ์ฆ์„ ๋ฐœ๊ธ‰๋ฐ›์Šต๋‹ˆ๋‹ค.

ํ•„์ˆ˜ ๊ธฐ๋Šฅ

  • ์›Œํฌ์ˆ ๋ชฉ๋ก ํŽ˜์ด์ง€ (์นด๋“œ ๊ทธ๋ฆฌ๋“œ ๋ ˆ์ด์•„์›ƒ, ํƒœ๊ทธ ํ•„ํ„ฐ๋ง)
  • ์›Œํฌ์ˆ ์ƒ์„ธ ํŽ˜์ด์ง€ (๊ฐ•์‚ฌ ์ •๋ณด, ์ปค๋ฆฌํ˜๋Ÿผ, ์ผ์ •, ์ •์›)
  • ๋“ฑ๋ก ํผ (์ด๋ฆ„, ์ด๋ฉ”์ผ, ์ „ํ™”๋ฒˆํ˜ธ, ์ฐธ์—ฌ ๋™๊ธฐ)
  • ์‹ค์‹œ๊ฐ„ ์ž”์—ฌ์„ ํ‘œ์‹œ (์ •์› ๋Œ€๋น„ ์‹ ์ฒญ์ž ์ˆ˜)
  • ๋‚˜์˜ ๋“ฑ๋ก ๋‚ด์—ญ ํŽ˜์ด์ง€ (์‹ ์ฒญํ•œ ์›Œํฌ์ˆ ๋ชฉ๋ก)
  • ์ถœ์„ ์ฒดํฌ ๊ธฐ๋Šฅ (๊ฐ•์‚ฌ์šฉ ๊ฐ„๋‹จ ์ธํ„ฐํŽ˜์ด์Šค)
  • ์ˆ˜๋ฃŒ์ฆ ์ž๋™ ๋ฐœ๊ธ‰ (์ถœ์„๋ฅ  80% ์ด์ƒ ์‹œ PDF ์ƒ์„ฑ ๋˜๋Š” ์ด๋ฏธ์ง€ ๋‹ค์šด๋กœ๋“œ)

๋ณด๋„ˆ์Šค ๊ธฐ๋Šฅ

  • ์›Œํฌ์ˆ ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ํ•„ํ„ฐ (๋””์ž์ธ, ๊ฐœ๋ฐœ, ๋งˆ์ผ€ํŒ… ๋“ฑ)
  • ๋“ฑ๋ก ๋Œ€๊ธฐ์—ด (์ •์› ์ดˆ๊ณผ ์‹œ ์ž๋™ ๋Œ€๊ธฐ ๋“ฑ๋ก)
  • ์ด๋ฉ”์ผ ์•Œ๋ฆผ (๋“ฑ๋ก ํ™•์ธ, ์›Œํฌ์ˆ ํ•˜๋ฃจ ์ „ ๋ฆฌ๋งˆ์ธ๋”)
  • ์ˆ˜๋ฃŒ์ฆ ์ปค์Šคํ„ฐ๋งˆ์ด์ง• (์ฐธ๊ฐ€์ž ์ด๋ฆ„, ์›Œํฌ์ˆ๋ช…, ๋‚ ์งœ ์ž๋™ ์‚ฝ์ž…)
  • ๊ฐ•์‚ฌ ํ‰๊ฐ€ & ํ›„๊ธฐ ์ž‘์„ฑ (๋ณ„์  + ํ…์ŠคํŠธ ๋ฆฌ๋ทฐ)

๊ธฐ์ˆ  ์Šคํƒ

  • Backend: Laravel 13.x (Eloquent ORM, ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜, ์ปจํŠธ๋กค๋Ÿฌ)
  • Frontend: React 19 (ํ•จ์ˆ˜ํ˜• ์ปดํฌ๋„ŒํŠธ, Hooks)
  • ํ’€์Šคํƒ ์—ฐ๊ฒฐ: Inertia.js (Laravel โ†” React SPA)
  • ์Šคํƒ€์ผ๋ง: Tailwind CSS
  • DB: SQLite (๊ฐœ๋ฐœ์šฉ) ๋˜๋Š” MySQL
  • PDF ์ƒ์„ฑ: Laravel Snappy ๋˜๋Š” DomPDF (์ˆ˜๋ฃŒ์ฆ ๋ฐœ๊ธ‰์šฉ)

ํ•™์Šต ๋ชฉํ‘œ

  • Laravel Breeze(React+Inertia) starter kit์œผ๋กœ ๋ชจ๋…ธ๋ฆฌ์Šค ํ’€์Šคํƒ ํ”„๋กœ์ ํŠธ ๊ตฌ์„ฑ
  • Inertia.js๋ฅผ ํ†ตํ•œ Laravel ์ปจํŠธ๋กค๋Ÿฌ โ†” React ์ปดํฌ๋„ŒํŠธ ๋ฐ์ดํ„ฐ ์ „๋‹ฌ
  • Eloquent ๊ด€๊ณ„ ์„ค์ • (Workshop hasMany Enrollment, User hasMany Enrollment)
  • ์กฐ๊ฑด๋ถ€ ๋ Œ๋”๋ง (์ •์› ๋งˆ๊ฐ, ๋Œ€๊ธฐ ์ƒํƒœ UI)
  • ํผ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ (Laravel Request Validation + React ์—๋Ÿฌ ํ‘œ์‹œ)
  • ํŒŒ์ผ/์ด๋ฏธ์ง€ ๋‹ค์šด๋กœ๋“œ (์ˆ˜๋ฃŒ์ฆ PDF ์ƒ์„ฑ)

์ด ํ”„๋กœ์ ํŠธ๋Š” ์‹ค์ œ ๋น„์ฆˆ๋‹ˆ์Šค ์‹œ๋‚˜๋ฆฌ์˜ค์—์„œ ์ฆ‰์‹œ ํ™œ์šฉ ๊ฐ€๋Šฅํ•œ ์ˆ˜์ค€์˜ ์›Œํฌ์ˆ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ์ž…๋‹ˆ๋‹ค.

์ œ์•ฝ ์กฐ๊ฑด

  • โ€ข Laravel 13.x + Inertia.js + React 19 ๋ชจ๋…ธ๋ฆฌ์Šค ํ’€์Šคํƒ ๊ตฌ์กฐ (๋ณ„๋„ API ๋ผ์šฐํŠธ ์—†์Œ)
  • โ€ข npx create laravel ๋˜๋Š” Laravel Breeze(React+Inertia) starter kit์œผ๋กœ ํ”„๋กœ์ ํŠธ ์ƒ์„ฑ
  • โ€ข SQLite ๋˜๋Š” MySQL ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์‚ฌ์šฉ
  • โ€ข Tailwind CSS๋กœ ๋ฐ˜์‘ํ˜• UI ๊ตฌํ˜„
  • โ€ข Eloquent ORM์œผ๋กœ Workshop, Enrollment, User ๋ชจ๋ธ ์„ค๊ณ„
  • โ€ข Inertia::render()๋กœ Laravel ์ปจํŠธ๋กค๋Ÿฌ์—์„œ React ์ปดํฌ๋„ŒํŠธ์— ๋ฐ์ดํ„ฐ ์ „๋‹ฌ
  • โ€ข ํผ ์ œ์ถœ ์‹œ Laravel Validation ์‚ฌ์šฉ (FormRequest ํด๋ž˜์Šค ๊ถŒ์žฅ)
  • โ€ข ์ˆ˜๋ฃŒ์ฆ ๋ฐœ๊ธ‰ ์‹œ Laravel Snappy ๋˜๋Š” DomPDF ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ํ™œ์šฉ
  • โ€ข ์‹ค์‹œ๊ฐ„ ์ž”์—ฌ์„ ๊ณ„์‚ฐ ๋กœ์ง (์ •์› - ํ˜„์žฌ ์‹ ์ฒญ์ž ์ˆ˜)
  • โ€ข ์ถœ์„ ์ฒดํฌ ๊ธฐ๋Šฅ์€ ๊ฐ„๋‹จํ•œ ์ฒดํฌ๋ฐ•์Šค ํ† ๊ธ€ (๊ฐ•์‚ฌ์šฉ ์ธํ„ฐํŽ˜์ด์Šค)
  • โ€ข React ์ปดํฌ๋„ŒํŠธ๋Š” ํ•จ์ˆ˜ํ˜• ์ปดํฌ๋„ŒํŠธ + Hooks(useState, useEffect) ์‚ฌ์šฉ
  • โ€ข ์ฝ”๋“œ ์ฃผ์„ ํ•„์ˆ˜ (ํŠนํžˆ Inertia.js ๋ฐ์ดํ„ฐ ํ๋ฆ„ ๋ถ€๋ถ„)

ํ”„๋กฌํ”„ํŠธ ๋ ˆ์‹œํ”ผ

์•„๋ž˜ ํ”„๋กฌํ”„ํŠธ๋ฅผ ์ˆœ์„œ๋Œ€๋กœ AI์—๊ฒŒ ๋ณด๋‚ด๋ฉด ๋ฉ๋‹ˆ๋‹ค. ๋ณต์‚ฌ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ๋ฐ”๋กœ ์‚ฌ์šฉํ•˜์„ธ์š”.

1
Step 1
mkdir workshop-enrollment-certificate-platform && cd workshop-enrollment-certificate-platform

์ด ํด๋”์—์„œ ํ”„๋กœ์ ํŠธ๋ฅผ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.

Laravel 13.x + Inertia.js + React 19 ํ’€์Šคํƒ ํ”„๋กœ์ ํŠธ๋ฅผ ์ƒ์„ฑํ•˜์„ธ์š”.

๋‹ค์Œ ๋ช…๋ น์–ด๋กœ Laravel Breeze(React+Inertia) starter kit์„ ์„ค์น˜ํ•ฉ๋‹ˆ๋‹ค:
```bash
composer create-project laravel/laravel .
composer require laravel/breeze --dev
php artisan breeze:install react
npm install
npm run dev
```

ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ:
- Laravel ๋ฐฑ์—”๋“œ (routes/web.php, app/Http/Controllers, app/Models)
- React ํ”„๋ก ํŠธ์—”๋“œ (resources/js/Pages, resources/js/Components)
- Inertia.js๊ฐ€ ๋‘˜์„ ์—ฐ๊ฒฐ (๋ณ„๋„ API ๋ผ์šฐํŠธ ๋ถˆํ•„์š”)

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋Š” SQLite๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. .env ํŒŒ์ผ์—์„œ DB_CONNECTION=sqlite๋กœ ์„ค์ •ํ•˜๊ณ , database/database.sqlite ํŒŒ์ผ์„ ์ƒ์„ฑํ•˜์„ธ์š”.

๋‹ค์Œ 3๊ฐœ์˜ ๋ชจ๋ธ๊ณผ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์„ ์ƒ์„ฑํ•˜์„ธ์š”:

1. Workshop ๋ชจ๋ธ (์›Œํฌ์ˆ ์ •๋ณด)
   - title (string, ์›Œํฌ์ˆ ์ œ๋ชฉ)
   - description (text, ์ƒ์„ธ ์„ค๋ช…)
   - instructor (string, ๊ฐ•์‚ฌ๋ช…)
   - category (string, ์นดํ…Œ๊ณ ๋ฆฌ: ๋””์ž์ธ/๊ฐœ๋ฐœ/๋งˆ์ผ€ํŒ… ๋“ฑ)
   - start_date (date, ์‹œ์ž‘์ผ)
   - end_date (date, ์ข…๋ฃŒ์ผ)
   - capacity (integer, ์ •์›)
   - image_url (string, nullable, ์ธ๋„ค์ผ ์ด๋ฏธ์ง€)
   - timestamps

2. Enrollment ๋ชจ๋ธ (๋“ฑ๋ก ์ •๋ณด)
   - workshop_id (foreignId, workshops ํ…Œ์ด๋ธ” ์ฐธ์กฐ)
   - name (string, ์ฐธ๊ฐ€์ž ์ด๋ฆ„)
   - email (string, ์ด๋ฉ”์ผ)
   - phone (string, ์ „ํ™”๋ฒˆํ˜ธ)
   - motivation (text, ์ฐธ์—ฌ ๋™๊ธฐ)
   - status (enum: 'registered', 'attended', 'completed', 'waiting', ๊ธฐ๋ณธ๊ฐ’ 'registered')
   - attended_sessions (integer, ๊ธฐ๋ณธ๊ฐ’ 0, ์ถœ์„ํ•œ ์„ธ์…˜ ์ˆ˜)
   - total_sessions (integer, ๊ธฐ๋ณธ๊ฐ’ 4, ์ „์ฒด ์„ธ์…˜ ์ˆ˜)
   - timestamps

3. User ๋ชจ๋ธ (๊ธฐ๋ณธ Laravel Breeze ์ œ๊ณต, ์ถ”๊ฐ€ ์ˆ˜์ • ๋ถˆํ•„์š”)

๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์‹คํ–‰:
```bash
php artisan migrate
```

Workshop ๋ชจ๋ธ์— Enrollment์™€์˜ ๊ด€๊ณ„ ์„ค์ •:
```php
public function enrollments()
{
    return $this->hasMany(Enrollment::class);
}

public function availableSeats()
{
    return $this->capacity - $this->enrollments()->count();
}
```

Enrollment ๋ชจ๋ธ์— Workshop๊ณผ์˜ ๊ด€๊ณ„ ์„ค์ •:
```php
public function workshop()
{
    return $this->belongsTo(Workshop::class);
}
```

์‹œ๋”(Seeder)๋ฅผ ์ž‘์„ฑํ•˜์—ฌ ๋”๋ฏธ ์›Œํฌ์ˆ ๋ฐ์ดํ„ฐ 5๊ฐœ๋ฅผ ์ƒ์„ฑํ•˜์„ธ์š”:
```bash
php artisan make:seeder WorkshopSeeder
```

WorkshopSeeder ๋‚ด์šฉ:
```php
Workshop::create([
    'title' => 'React 19 ์‹ค์ „ ์›Œํฌ์ˆ',
    'description' => 'React ์ตœ์‹  ๊ธฐ๋Šฅ์„ ํ™œ์šฉํ•œ SPA ๊ฐœ๋ฐœ ์‹ค์Šต',
    'instructor' => '๊น€๊ฐœ๋ฐœ',
    'category' => '๊ฐœ๋ฐœ',
    'start_date' => now()->addDays(10),
    'end_date' => now()->addDays(12),
    'capacity' => 20,
    'image_url' => 'https://via.placeholder.com/400x300?text=React+Workshop',
]);
// ๋‚˜๋จธ์ง€ 4๊ฐœ ์›Œํฌ์ˆ๋„ ์œ ์‚ฌํ•˜๊ฒŒ ์ƒ์„ฑ (๋””์ž์ธ, ๋งˆ์ผ€ํŒ… ์นดํ…Œ๊ณ ๋ฆฌ ํฌํ•จ)
```

์‹œ๋” ์‹คํ–‰:
```bash
php artisan db:seed --class=WorkshopSeeder
```

ํ”„๋กœ์ ํŠธ๊ฐ€ ์ •์ƒ์ ์œผ๋กœ ์‹คํ–‰๋˜๋Š”์ง€ ํ™•์ธ:
```bash
php artisan serve
```

http://localhost:8000 ์ ‘์† ์‹œ Laravel Breeze ๊ธฐ๋ณธ ํ™”๋ฉด์ด ๋‚˜ํƒ€๋‚˜๋ฉด ์„ฑ๊ณต์ž…๋‹ˆ๋‹ค.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: Laravel 13.x + Inertia.js + React 19 ํ”„๋กœ์ ํŠธ๊ฐ€ ์ƒ์„ฑ๋˜๊ณ , Workshop, Enrollment ๋ชจ๋ธ๊ณผ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์ด ์™„๋ฃŒ๋ฉ๋‹ˆ๋‹ค. ๋”๋ฏธ ์›Œํฌ์ˆ ๋ฐ์ดํ„ฐ 5๊ฐœ๊ฐ€ DB์— ์ €์žฅ๋˜๋ฉฐ, ํ”„๋กœ์ ํŠธ๊ฐ€ ๋กœ์ปฌ์—์„œ ์ •์ƒ ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค.

2
Step 2
์›Œํฌ์ˆ ๋ชฉ๋ก ํŽ˜์ด์ง€๋ฅผ ๊ตฌํ˜„ํ•˜์„ธ์š”.

Laravel ์ปจํŠธ๋กค๋Ÿฌ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค:
```bash
php artisan make:controller WorkshopController
```

WorkshopController์— index ๋ฉ”์„œ๋“œ ์ž‘์„ฑ:
```php
use Inertia\Inertia;
use App\Models\Workshop;

public function index()
{
    $workshops = Workshop::withCount('enrollments')
        ->orderBy('start_date', 'asc')
        ->get()
        ->map(function ($workshop) {
            return [
                'id' => $workshop->id,
                'title' => $workshop->title,
                'instructor' => $workshop->instructor,
                'category' => $workshop->category,
                'start_date' => $workshop->start_date->format('Y-m-d'),
                'capacity' => $workshop->capacity,
                'enrolled' => $workshop->enrollments_count,
                'available' => $workshop->capacity - $workshop->enrollments_count,
                'image_url' => $workshop->image_url,
            ];
        });

    return Inertia::render('Workshops/Index', [
        'workshops' => $workshops,
    ]);
}
```

routes/web.php์— ๋ผ์šฐํŠธ ์ถ”๊ฐ€:
```php
use App\Http\Controllers\WorkshopController;

Route::get('/workshops', [WorkshopController::class, 'index'])->name('workshops.index');
```

React ์ปดํฌ๋„ŒํŠธ ์ƒ์„ฑ:
resources/js/Pages/Workshops/Index.jsx ํŒŒ์ผ์„ ๋งŒ๋“ค๊ณ  ๋‹ค์Œ ๋‚ด์šฉ์„ ์ž‘์„ฑํ•˜์„ธ์š”:

```jsx
import React, { useState } from 'react';
import { Head, Link } from '@inertiajs/react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';

export default function Index({ auth, workshops }) {
    const [selectedCategory, setSelectedCategory] = useState('all');

    const categories = ['all', '๊ฐœ๋ฐœ', '๋””์ž์ธ', '๋งˆ์ผ€ํŒ…'];

    const filteredWorkshops = selectedCategory === 'all'
        ? workshops
        : workshops.filter(w => w.category === selectedCategory);

    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="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
                        <h1 className="text-3xl font-bold mb-6">์›Œํฌ์ˆ ๋ชฉ๋ก</h1>

                        {/* ์นดํ…Œ๊ณ ๋ฆฌ ํ•„ํ„ฐ */}
                        <div className="flex gap-2 mb-6">
                            {categories.map(cat => (
                                <button
                                    key={cat}
                                    onClick={() => setSelectedCategory(cat)}
                                    className={`px-4 py-2 rounded-lg font-medium transition ${
                                        selectedCategory === cat
                                            ? 'bg-indigo-600 text-white'
                                            : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
                                    }`}
                                >
                                    {cat === 'all' ? '์ „์ฒด' : cat}
                                </button>
                            ))}
                        </div>

                        {/* ์›Œํฌ์ˆ ์นด๋“œ ๊ทธ๋ฆฌ๋“œ */}
                        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
                            {filteredWorkshops.map(workshop => (
                                <div key={workshop.id} className="border rounded-lg overflow-hidden shadow hover:shadow-lg transition">
                                    <img src={workshop.image_url} alt={workshop.title} className="w-full h-48 object-cover" />
                                    <div className="p-4">
                                        <span className="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded mb-2">
                                            {workshop.category}
                                        </span>
                                        <h3 className="text-xl font-bold mb-2">{workshop.title}</h3>
                                        <p className="text-gray-600 text-sm mb-2">๊ฐ•์‚ฌ: {workshop.instructor}</p>
                                        <p className="text-gray-600 text-sm mb-4">์‹œ์ž‘์ผ: {workshop.start_date}</p>
                                        <div className="flex justify-between items-center">
                                            <span className={`text-sm font-medium ${
                                                workshop.available > 0 ? 'text-green-600' : 'text-red-600'
                                            }`}>
                                                {workshop.available > 0
                                                    ? `์ž”์—ฌ์„ ${workshop.available}์„`
                                                    : '๋งˆ๊ฐ'}
                                            </span>
                                            <Link
                                                href={`/workshops/${workshop.id}`}
                                                className="bg-indigo-600 text-white px-4 py-2 rounded hover:bg-indigo-700 transition"
                                            >
                                                ์ž์„ธํžˆ ๋ณด๊ธฐ
                                            </Link>
                                        </div>
                                    </div>
                                </div>
                            ))}
                        </div>
                    </div>
                </div>
            </div>
        </AuthenticatedLayout>
    );
}
```

๋„ค๋น„๊ฒŒ์ด์…˜ ๋ฉ”๋‰ด์— ์›Œํฌ์ˆ ๋งํฌ ์ถ”๊ฐ€:
resources/js/Layouts/AuthenticatedLayout.jsx ํŒŒ์ผ์—์„œ ๋„ค๋น„๊ฒŒ์ด์…˜ ๋ถ€๋ถ„์— ๋‹ค์Œ ๋งํฌ๋ฅผ ์ถ”๊ฐ€ํ•˜์„ธ์š”:
```jsx
<Link href="/workshops" className="...">
    ์›Œํฌ์ˆ
</Link>
```

๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/workshops ์ ‘์† ์‹œ ์›Œํฌ์ˆ ์นด๋“œ ๊ทธ๋ฆฌ๋“œ๊ฐ€ ํ‘œ์‹œ๋˜๊ณ , ์นดํ…Œ๊ณ ๋ฆฌ ํ•„ํ„ฐ๊ฐ€ ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: ์›Œํฌ์ˆ ๋ชฉ๋ก ํŽ˜์ด์ง€๊ฐ€ ์™„์„ฑ๋ฉ๋‹ˆ๋‹ค. ์นด๋“œ ๊ทธ๋ฆฌ๋“œ ๋ ˆ์ด์•„์›ƒ์œผ๋กœ ์›Œํฌ์ˆ์ด ํ‘œ์‹œ๋˜๋ฉฐ, ์นดํ…Œ๊ณ ๋ฆฌ ํ•„ํ„ฐ ๋ฒ„ํŠผ์„ ํด๋ฆญํ•˜๋ฉด ํ•ด๋‹น ์นดํ…Œ๊ณ ๋ฆฌ์˜ ์›Œํฌ์ˆ๋งŒ ๋ณด์ž…๋‹ˆ๋‹ค. ์ž”์—ฌ์„์ด ์‹ค์‹œ๊ฐ„์œผ๋กœ ๊ณ„์‚ฐ๋˜์–ด ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.

3
Step 3
์›Œํฌ์ˆ ์ƒ์„ธ ํŽ˜์ด์ง€์™€ ๋“ฑ๋ก ํผ์„ ๊ตฌํ˜„ํ•˜์„ธ์š”.

WorkshopController์— show ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€:
```php
public function show($id)
{
    $workshop = Workshop::withCount('enrollments')->findOrFail($id);

    return Inertia::render('Workshops/Show', [
        'workshop' => [
            'id' => $workshop->id,
            'title' => $workshop->title,
            'description' => $workshop->description,
            'instructor' => $workshop->instructor,
            'category' => $workshop->category,
            'start_date' => $workshop->start_date->format('Y-m-d'),
            'end_date' => $workshop->end_date->format('Y-m-d'),
            'capacity' => $workshop->capacity,
            'enrolled' => $workshop->enrollments_count,
            'available' => $workshop->capacity - $workshop->enrollments_count,
            'image_url' => $workshop->image_url,
        ],
    ]);
}
```

routes/web.php์— ๋ผ์šฐํŠธ ์ถ”๊ฐ€:
```php
Route::get('/workshops/{id}', [WorkshopController::class, 'show'])->name('workshops.show');
```

React ์ปดํฌ๋„ŒํŠธ ์ƒ์„ฑ:
resources/js/Pages/Workshops/Show.jsx ํŒŒ์ผ์„ ๋งŒ๋“ค๊ณ  ๋‹ค์Œ ๋‚ด์šฉ์„ ์ž‘์„ฑํ•˜์„ธ์š”:

```jsx
import React, { useState } from 'react';
import { Head, useForm } from '@inertiajs/react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';

export default function Show({ auth, workshop }) {
    const [showForm, setShowForm] = useState(false);

    const { data, setData, post, processing, errors } = useForm({
        name: '',
        email: '',
        phone: '',
        motivation: '',
    });

    const handleSubmit = (e) => {
        e.preventDefault();
        post(`/workshops/${workshop.id}/enroll`, {
            onSuccess: () => {
                alert('๋“ฑ๋ก์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!');
                setShowForm(false);
            },
        });
    };

    return (
        <AuthenticatedLayout user={auth.user}>
            <Head title={workshop.title} />

            <div className="py-12">
                <div className="max-w-4xl mx-auto sm:px-6 lg:px-8">
                    <div className="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
                        <img src={workshop.image_url} alt={workshop.title} className="w-full h-64 object-cover rounded-lg mb-6" />

                        <span className="inline-block bg-blue-100 text-blue-800 text-sm px-3 py-1 rounded mb-4">
                            {workshop.category}
                        </span>

                        <h1 className="text-4xl font-bold mb-4">{workshop.title}</h1>
                        <p className="text-gray-600 mb-2">๊ฐ•์‚ฌ: <strong>{workshop.instructor}</strong></p>
                        <p className="text-gray-600 mb-2">๊ธฐ๊ฐ„: {workshop.start_date} ~ {workshop.end_date}</p>
                        <p className="text-gray-600 mb-6">์ •์›: {workshop.capacity}๋ช… (์ž”์—ฌ์„ {workshop.available}์„)</p>

                        <div className="prose max-w-none mb-6">
                            <h2 className="text-2xl font-bold mb-2">์›Œํฌ์ˆ ์†Œ๊ฐœ</h2>
                            <p>{workshop.description}</p>
                        </div>

                        {workshop.available > 0 ? (
                            <button
                                onClick={() => setShowForm(!showForm)}
                                className="bg-indigo-600 text-white px-6 py-3 rounded-lg hover:bg-indigo-700 transition font-medium"
                            >
                                {showForm ? '๋“ฑ๋ก ํผ ๋‹ซ๊ธฐ' : '์ง€๊ธˆ ๋“ฑ๋กํ•˜๊ธฐ'}
                            </button>
                        ) : (
                            <div className="bg-red-100 text-red-800 px-4 py-3 rounded-lg">
                                ํ˜„์žฌ ์ •์›์ด ๋งˆ๊ฐ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.
                            </div>
                        )}

                        {showForm && (
                            <form onSubmit={handleSubmit} className="mt-6 space-y-4 border-t pt-6">
                                <h3 className="text-2xl font-bold mb-4">๋“ฑ๋ก ์ •๋ณด ์ž…๋ ฅ</h3>

                                <div>
                                    <label className="block text-sm font-medium mb-1">์ด๋ฆ„</label>
                                    <input
                                        type="text"
                                        value={data.name}
                                        onChange={e => setData('name', e.target.value)}
                                        className="w-full border rounded-lg px-4 py-2"
                                        required
                                    />
                                    {errors.name && <p className="text-red-600 text-sm mt-1">{errors.name}</p>}
                                </div>

                                <div>
                                    <label className="block text-sm font-medium mb-1">์ด๋ฉ”์ผ</label>
                                    <input
                                        type="email"
                                        value={data.email}
                                        onChange={e => setData('email', e.target.value)}
                                        className="w-full border rounded-lg px-4 py-2"
                                        required
                                    />
                                    {errors.email && <p className="text-red-600 text-sm mt-1">{errors.email}</p>}
                                </div>

                                <div>
                                    <label className="block text-sm font-medium mb-1">์ „ํ™”๋ฒˆํ˜ธ</label>
                                    <input
                                        type="tel"
                                        value={data.phone}
                                        onChange={e => setData('phone', e.target.value)}
                                        className="w-full border rounded-lg px-4 py-2"
                                        required
                                    />
                                    {errors.phone && <p className="text-red-600 text-sm mt-1">{errors.phone}</p>}
                                </div>

                                <div>
                                    <label className="block text-sm font-medium mb-1">์ฐธ์—ฌ ๋™๊ธฐ</label>
                                    <textarea
                                        value={data.motivation}
                                        onChange={e => setData('motivation', e.target.value)}
                                        className="w-full border rounded-lg px-4 py-2 h-32"
                                        required
                                    />
                                    {errors.motivation && <p className="text-red-600 text-sm mt-1">{errors.motivation}</p>}
                                </div>

                                <button
                                    type="submit"
                                    disabled={processing}
                                    className="bg-green-600 text-white px-6 py-3 rounded-lg hover:bg-green-700 transition font-medium disabled:opacity-50"
                                >
                                    {processing ? '๋“ฑ๋ก ์ค‘...' : '๋“ฑ๋ก ์™„๋ฃŒ'}
                                </button>
                            </form>
                        )}
                    </div>
                </div>
            </div>
        </AuthenticatedLayout>
    );
}
```

WorkshopController์— enroll ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€ (๋“ฑ๋ก ์ฒ˜๋ฆฌ):
```php
use App\Models\Enrollment;
use Illuminate\Http\Request;

public function enroll(Request $request, $id)
{
    $workshop = Workshop::findOrFail($id);

    // ์ •์› ์ฒดํฌ
    if ($workshop->enrollments()->count() >= $workshop->capacity) {
        return back()->withErrors(['error' => '์ •์›์ด ๋งˆ๊ฐ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.']);
    }

    $validated = $request->validate([
        'name' => 'required|string|max:255',
        'email' => 'required|email|max:255',
        'phone' => 'required|string|max:20',
        'motivation' => 'required|string',
    ]);

    Enrollment::create([
        'workshop_id' => $workshop->id,
        'name' => $validated['name'],
        'email' => $validated['email'],
        'phone' => $validated['phone'],
        'motivation' => $validated['motivation'],
        'status' => 'registered',
        'total_sessions' => 4, // ๊ธฐ๋ณธ 4ํšŒ์ฐจ
    ]);

    return redirect()->route('workshops.index')->with('success', '๋“ฑ๋ก์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!');
}
```

routes/web.php์— ๋ผ์šฐํŠธ ์ถ”๊ฐ€:
```php
Route::post('/workshops/{id}/enroll', [WorkshopController::class, 'enroll'])->name('workshops.enroll');
```

๋ธŒ๋ผ์šฐ์ €์—์„œ ์›Œํฌ์ˆ ์ƒ์„ธ ํŽ˜์ด์ง€ ์ ‘์† ํ›„ ๋“ฑ๋ก ํผ์„ ์ž‘์„ฑํ•˜๊ณ  ์ œ์ถœํ•˜์„ธ์š”. DB์— Enrollment ๋ ˆ์ฝ”๋“œ๊ฐ€ ์ƒ์„ฑ๋˜๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: ์›Œํฌ์ˆ ์ƒ์„ธ ํŽ˜์ด์ง€๊ฐ€ ์™„์„ฑ๋˜๊ณ , ๋“ฑ๋ก ํผ์ด ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค. ํผ ์ œ์ถœ ์‹œ Laravel Validation์ด ์ž‘๋™ํ•˜๋ฉฐ, ์ •์› ์ดˆ๊ณผ ์‹œ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค. ๋“ฑ๋ก ์„ฑ๊ณต ์‹œ DB์— ๋ฐ์ดํ„ฐ๊ฐ€ ์ €์žฅ๋ฉ๋‹ˆ๋‹ค.

4
Step 4
๋‚˜์˜ ๋“ฑ๋ก ๋‚ด์—ญ ํŽ˜์ด์ง€๋ฅผ ๊ตฌํ˜„ํ•˜์„ธ์š”.

WorkshopController์— myEnrollments ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€:
```php
public function myEnrollments(Request $request)
{
    // ํ˜„์žฌ ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž์˜ ์ด๋ฉ”์ผ๋กœ ๋“ฑ๋ก ๋‚ด์—ญ ์กฐํšŒ (๊ฐ„๋‹จํ•œ ๊ตฌํ˜„)
    // ์‹ค๋ฌด์—์„œ๋Š” User ๋ชจ๋ธ๊ณผ ์—ฐ๊ฒฐํ•˜๋Š” ๊ฒƒ์ด ์ข‹์ง€๋งŒ, ์—ฌ๊ธฐ์„œ๋Š” ์ด๋ฉ”์ผ ๊ธฐ๋ฐ˜์œผ๋กœ ์กฐํšŒ
    $email = $request->user()->email;

    $enrollments = Enrollment::with('workshop')
        ->where('email', $email)
        ->orderBy('created_at', 'desc')
        ->get()
        ->map(function ($enrollment) {
            return [
                'id' => $enrollment->id,
                'workshop_title' => $enrollment->workshop->title,
                'workshop_instructor' => $enrollment->workshop->instructor,
                'workshop_start_date' => $enrollment->workshop->start_date->format('Y-m-d'),
                'status' => $enrollment->status,
                'attended_sessions' => $enrollment->attended_sessions,
                'total_sessions' => $enrollment->total_sessions,
                'attendance_rate' => round(($enrollment->attended_sessions / $enrollment->total_sessions) * 100),
                'can_certificate' => $enrollment->attended_sessions >= ($enrollment->total_sessions * 0.8),
            ];
        });

    return Inertia::render('Workshops/MyEnrollments', [
        'enrollments' => $enrollments,
    ]);
}
```

routes/web.php์— ๋ผ์šฐํŠธ ์ถ”๊ฐ€:
```php
Route::get('/my-enrollments', [WorkshopController::class, 'myEnrollments'])->name('workshops.my-enrollments');
```

React ์ปดํฌ๋„ŒํŠธ ์ƒ์„ฑ:
resources/js/Pages/Workshops/MyEnrollments.jsx ํŒŒ์ผ์„ ๋งŒ๋“ค๊ณ  ๋‹ค์Œ ๋‚ด์šฉ์„ ์ž‘์„ฑํ•˜์„ธ์š”:

```jsx
import React from 'react';
import { Head, Link } from '@inertiajs/react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';

export default function MyEnrollments({ auth, enrollments }) {
    const statusLabels = {
        registered: '๋“ฑ๋ก ์™„๋ฃŒ',
        attended: '์ถœ์„ ์ค‘',
        completed: '์ˆ˜๋ฃŒ',
        waiting: '๋Œ€๊ธฐ',
    };

    const statusColors = {
        registered: 'bg-blue-100 text-blue-800',
        attended: 'bg-yellow-100 text-yellow-800',
        completed: 'bg-green-100 text-green-800',
        waiting: 'bg-gray-100 text-gray-800',
    };

    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="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
                        <h1 className="text-3xl font-bold mb-6">๋‚˜์˜ ๋“ฑ๋ก ๋‚ด์—ญ</h1>

                        {enrollments.length === 0 ? (
                            <div className="text-center py-12">
                                <p className="text-gray-600 mb-4">๋“ฑ๋กํ•œ ์›Œํฌ์ˆ์ด ์—†์Šต๋‹ˆ๋‹ค.</p>
                                <Link
                                    href="/workshops"
                                    className="bg-indigo-600 text-white px-6 py-3 rounded-lg hover:bg-indigo-700 transition"
                                >
                                    ์›Œํฌ์ˆ ๋‘˜๋Ÿฌ๋ณด๊ธฐ
                                </Link>
                            </div>
                        ) : (
                            <div className="space-y-4">
                                {enrollments.map(enrollment => (
                                    <div key={enrollment.id} className="border rounded-lg p-6 hover:shadow-lg transition">
                                        <div className="flex justify-between items-start mb-4">
                                            <div>
                                                <h3 className="text-xl font-bold mb-1">{enrollment.workshop_title}</h3>
                                                <p className="text-gray-600 text-sm">๊ฐ•์‚ฌ: {enrollment.workshop_instructor}</p>
                                                <p className="text-gray-600 text-sm">์‹œ์ž‘์ผ: {enrollment.workshop_start_date}</p>
                                            </div>
                                            <span className={`px-3 py-1 rounded text-sm font-medium ${statusColors[enrollment.status]}`}>
                                                {statusLabels[enrollment.status]}
                                            </span>
                                        </div>

                                        <div className="bg-gray-50 rounded-lg p-4 mb-4">
                                            <div className="flex justify-between items-center mb-2">
                                                <span className="text-sm font-medium">์ถœ์„๋ฅ </span>
                                                <span className="text-sm font-bold">{enrollment.attendance_rate}%</span>
                                            </div>
                                            <div className="w-full bg-gray-200 rounded-full h-2">
                                                <div
                                                    className={`h-2 rounded-full ${
                                                        enrollment.attendance_rate >= 80 ? 'bg-green-600' : 'bg-yellow-600'
                                                    }`}
                                                    style={{ width: `${enrollment.attendance_rate}%` }}
                                                />
                                            </div>
                                            <p className="text-xs text-gray-600 mt-1">
                                                {enrollment.attended_sessions} / {enrollment.total_sessions} ์„ธ์…˜ ์ถœ์„
                                            </p>
                                        </div>

                                        {enrollment.can_certificate ? (
                                            <Link
                                                href={`/certificates/${enrollment.id}`}
                                                className="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 transition inline-block"
                                            >
                                                ์ˆ˜๋ฃŒ์ฆ ๋ฐœ๊ธ‰๋ฐ›๊ธฐ
                                            </Link>
                                        ) : (
                                            <p className="text-sm text-gray-600">
                                                ์ถœ์„๋ฅ  80% ์ด์ƒ ๋‹ฌ์„ฑ ์‹œ ์ˆ˜๋ฃŒ์ฆ์„ ๋ฐœ๊ธ‰๋ฐ›์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
                                            </p>
                                        )}
                                    </div>
                                ))}
                            </div>
                        )}
                    </div>
                </div>
            </div>
        </AuthenticatedLayout>
    );
}
```

๋„ค๋น„๊ฒŒ์ด์…˜ ๋ฉ”๋‰ด์— "๋‚˜์˜ ๋“ฑ๋ก ๋‚ด์—ญ" ๋งํฌ ์ถ”๊ฐ€:
resources/js/Layouts/AuthenticatedLayout.jsx ํŒŒ์ผ์—์„œ ๋„ค๋น„๊ฒŒ์ด์…˜ ๋ถ€๋ถ„์— ๋‹ค์Œ ๋งํฌ๋ฅผ ์ถ”๊ฐ€ํ•˜์„ธ์š”:
```jsx
<Link href="/my-enrollments" className="...">
    ๋‚˜์˜ ๋“ฑ๋ก ๋‚ด์—ญ
</Link>
```

๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:8000/my-enrollments ์ ‘์† ์‹œ ๋“ฑ๋กํ•œ ์›Œํฌ์ˆ ๋ชฉ๋ก๊ณผ ์ถœ์„๋ฅ ์ด ํ‘œ์‹œ๋˜๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: ๋‚˜์˜ ๋“ฑ๋ก ๋‚ด์—ญ ํŽ˜์ด์ง€๊ฐ€ ์™„์„ฑ๋ฉ๋‹ˆ๋‹ค. ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž์˜ ์ด๋ฉ”์ผ๋กœ ๋“ฑ๋กํ•œ ์›Œํฌ์ˆ ๋ชฉ๋ก์ด ํ‘œ์‹œ๋˜๋ฉฐ, ์ถœ์„๋ฅ ์ด ํ”„๋กœ๊ทธ๋ ˆ์Šค ๋ฐ”๋กœ ์‹œ๊ฐํ™”๋ฉ๋‹ˆ๋‹ค. ์ถœ์„๋ฅ  80% ์ด์ƒ ์‹œ ์ˆ˜๋ฃŒ์ฆ ๋ฐœ๊ธ‰ ๋ฒ„ํŠผ์ด ํ™œ์„ฑํ™”๋ฉ๋‹ˆ๋‹ค.

5
Step 5
์ถœ์„ ์ฒดํฌ ๊ธฐ๋Šฅ(๊ฐ•์‚ฌ์šฉ ๊ฐ„๋‹จ ์ธํ„ฐํŽ˜์ด์Šค)์„ ๊ตฌํ˜„ํ•˜์„ธ์š”.

WorkshopController์— attendanceForm ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€ (์ถœ์„ ์ฒดํฌ ํŽ˜์ด์ง€):
```php
public function attendanceForm($id)
{
    $workshop = Workshop::findOrFail($id);
    $enrollments = $workshop->enrollments()->get()->map(function ($enrollment) {
        return [
            'id' => $enrollment->id,
            'name' => $enrollment->name,
            'email' => $enrollment->email,
            'attended_sessions' => $enrollment->attended_sessions,
            'total_sessions' => $enrollment->total_sessions,
        ];
    });

    return Inertia::render('Workshops/Attendance', [
        'workshop' => [
            'id' => $workshop->id,
            'title' => $workshop->title,
        ],
        'enrollments' => $enrollments,
    ]);
}

public function markAttendance(Request $request, $id)
{
    $validated = $request->validate([
        'enrollment_id' => 'required|exists:enrollments,id',
    ]);

    $enrollment = Enrollment::findOrFail($validated['enrollment_id']);
    $enrollment->attended_sessions = min($enrollment->attended_sessions + 1, $enrollment->total_sessions);

    // ์ถœ์„๋ฅ  80% ์ด์ƒ ์‹œ ์ƒํƒœ๋ฅผ 'completed'๋กœ ๋ณ€๊ฒฝ
    if ($enrollment->attended_sessions >= ($enrollment->total_sessions * 0.8)) {
        $enrollment->status = 'completed';
    } else {
        $enrollment->status = 'attended';
    }

    $enrollment->save();

    return back()->with('success', '์ถœ์„์ด ๊ธฐ๋ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค.');
}
```

routes/web.php์— ๋ผ์šฐํŠธ ์ถ”๊ฐ€:
```php
Route::get('/workshops/{id}/attendance', [WorkshopController::class, 'attendanceForm'])->name('workshops.attendance');
Route::post('/workshops/{id}/attendance', [WorkshopController::class, 'markAttendance'])->name('workshops.mark-attendance');
```

React ์ปดํฌ๋„ŒํŠธ ์ƒ์„ฑ:
resources/js/Pages/Workshops/Attendance.jsx ํŒŒ์ผ์„ ๋งŒ๋“ค๊ณ  ๋‹ค์Œ ๋‚ด์šฉ์„ ์ž‘์„ฑํ•˜์„ธ์š”:

```jsx
import React from 'react';
import { Head, useForm } from '@inertiajs/react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';

export default function Attendance({ auth, workshop, enrollments }) {
    const { post, processing } = useForm();

    const handleAttendance = (enrollmentId) => {
        if (confirm('์ถœ์„ ์ฒ˜๋ฆฌํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?')) {
            post(`/workshops/${workshop.id}/attendance`, {
                data: { enrollment_id: enrollmentId },
                onSuccess: () => alert('์ถœ์„์ด ๊ธฐ๋ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค!'),
            });
        }
    };

    return (
        <AuthenticatedLayout user={auth.user}>
            <Head title={`์ถœ์„ ์ฒดํฌ - ${workshop.title}`} />

            <div className="py-12">
                <div className="max-w-5xl mx-auto sm:px-6 lg:px-8">
                    <div className="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
                        <h1 className="text-3xl font-bold mb-2">{workshop.title}</h1>
                        <p className="text-gray-600 mb-6">์ถœ์„ ์ฒดํฌ (๊ฐ•์‚ฌ์šฉ)</p>

                        {enrollments.length === 0 ? (
                            <p className="text-gray-600">๋“ฑ๋ก๋œ ์ฐธ๊ฐ€์ž๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.</p>
                        ) : (
                            <div className="overflow-x-auto">
                                <table className="min-w-full divide-y divide-gray-200">
                                    <thead className="bg-gray-50">
                                        <tr>
                                            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                                                ์ด๋ฆ„
                                            </th>
                                            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                                                ์ด๋ฉ”์ผ
                                            </th>
                                            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                                                ์ถœ์„ ํ˜„ํ™ฉ
                                            </th>
                                            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                                                ์•ก์…˜
                                            </th>
                                        </tr>
                                    </thead>
                                    <tbody className="bg-white divide-y divide-gray-200">
                                        {enrollments.map(enrollment => (
                                            <tr key={enrollment.id}>
                                                <td className="px-6 py-4 whitespace-nowrap">
                                                    <div className="text-sm font-medium text-gray-900">{enrollment.name}</div>
                                                </td>
                                                <td className="px-6 py-4 whitespace-nowrap">
                                                    <div className="text-sm text-gray-500">{enrollment.email}</div>
                                                </td>
                                                <td className="px-6 py-4 whitespace-nowrap">
                                                    <div className="text-sm text-gray-900">
                                                        {enrollment.attended_sessions} / {enrollment.total_sessions} ์„ธ์…˜
                                                    </div>
                                                    <div className="w-32 bg-gray-200 rounded-full h-2 mt-1">
                                                        <div
                                                            className="bg-blue-600 h-2 rounded-full"
                                                            style={{
                                                                width: `${(enrollment.attended_sessions / enrollment.total_sessions) * 100}%`,
                                                            }}
                                                        />
                                                    </div>
                                                </td>
                                                <td className="px-6 py-4 whitespace-nowrap text-sm">
                                                    <button
                                                        onClick={() => handleAttendance(enrollment.id)}
                                                        disabled={processing || enrollment.attended_sessions >= enrollment.total_sessions}
                                                        className="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
                                                    >
                                                        {enrollment.attended_sessions >= enrollment.total_sessions
                                                            ? '์ถœ์„ ์™„๋ฃŒ'
                                                            : '์ถœ์„ ์ฒดํฌ'}
                                                    </button>
                                                </td>
                                            </tr>
                                        ))}
                                    </tbody>
                                </table>
                            </div>
                        )}
                    </div>
                </div>
            </div>
        </AuthenticatedLayout>
    );
}
```

์›Œํฌ์ˆ ์ƒ์„ธ ํŽ˜์ด์ง€(Show.jsx)์— ์ถœ์„ ์ฒดํฌ ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•˜๋Š” ๋ฒ„ํŠผ ์ถ”๊ฐ€:
```jsx
<Link
    href={`/workshops/${workshop.id}/attendance`}
    className="bg-purple-600 text-white px-4 py-2 rounded hover:bg-purple-700 transition ml-2"
>
    ์ถœ์„ ์ฒดํฌ (๊ฐ•์‚ฌ์šฉ)
</Link>
```

๋ธŒ๋ผ์šฐ์ €์—์„œ ์ถœ์„ ์ฒดํฌ ํŽ˜์ด์ง€์— ์ ‘์†ํ•˜์—ฌ ์ฐธ๊ฐ€์ž์˜ ์ถœ์„์„ ๊ธฐ๋กํ•˜์„ธ์š”. ์ถœ์„ ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ DB์˜ attended_sessions ๊ฐ’์ด ์ฆ๊ฐ€ํ•˜๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”.

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: ์ถœ์„ ์ฒดํฌ ํŽ˜์ด์ง€๊ฐ€ ์™„์„ฑ๋ฉ๋‹ˆ๋‹ค. ๊ฐ•์‚ฌ๋Š” ์ฐธ๊ฐ€์ž๋ณ„๋กœ ์ถœ์„ ๋ฒ„ํŠผ์„ ํด๋ฆญํ•˜์—ฌ ์ถœ์„์„ ๊ธฐ๋กํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์ถœ์„ ํ˜„ํ™ฉ์ด ํ”„๋กœ๊ทธ๋ ˆ์Šค ๋ฐ”๋กœ ์‹œ๊ฐํ™”๋ฉ๋‹ˆ๋‹ค. ์ถœ์„๋ฅ  80% ์ด์ƒ ์‹œ ์ž๋™์œผ๋กœ ์ƒํƒœ๊ฐ€ 'completed'๋กœ ๋ณ€๊ฒฝ๋ฉ๋‹ˆ๋‹ค.

6
Step 6
์ˆ˜๋ฃŒ์ฆ ์ž๋™ ๋ฐœ๊ธ‰ ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ•˜์„ธ์š”.

Laravel์—์„œ PDF ์ƒ์„ฑ์„ ์œ„ํ•ด DomPDF ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์„ค์น˜ํ•ฉ๋‹ˆ๋‹ค:
```bash
composer require barryvdh/laravel-dompdf
```

WorkshopController์— certificate ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€:
```php
use Barryvdh\DomPDF\Facade\Pdf;

public function certificate($enrollmentId)
{
    $enrollment = Enrollment::with('workshop')->findOrFail($enrollmentId);

    // ์ถœ์„๋ฅ  80% ๋ฏธ๋งŒ์ด๋ฉด ์ ‘๊ทผ ๊ฑฐ๋ถ€
    if ($enrollment->attended_sessions < ($enrollment->total_sessions * 0.8)) {
        return back()->withErrors(['error' => '์ถœ์„๋ฅ  80% ์ด์ƒ ๋‹ฌ์„ฑ ์‹œ ์ˆ˜๋ฃŒ์ฆ์„ ๋ฐœ๊ธ‰๋ฐ›์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.']);
    }

    // ์ˆ˜๋ฃŒ์ฆ ๋ฐ์ดํ„ฐ
    $data = [
        'name' => $enrollment->name,
        'workshop_title' => $enrollment->workshop->title,
        'instructor' => $enrollment->workshop->instructor,
        'start_date' => $enrollment->workshop->start_date->format('Y๋…„ m์›” d์ผ'),
        'end_date' => $enrollment->workshop->end_date->format('Y๋…„ m์›” d์ผ'),
        'issue_date' => now()->format('Y๋…„ m์›” d์ผ'),
        'attendance_rate' => round(($enrollment->attended_sessions / $enrollment->total_sessions) * 100),
    ];

    // PDF ์ƒ์„ฑ
    $pdf = Pdf::loadView('certificates.template', $data);

    return $pdf->download('certificate_' . $enrollment->id . '.pdf');
}
```

routes/web.php์— ๋ผ์šฐํŠธ ์ถ”๊ฐ€:
```php
Route::get('/certificates/{enrollmentId}', [WorkshopController::class, 'certificate'])->name('certificates.show');
```

์ˆ˜๋ฃŒ์ฆ ํ…œํ”Œ๋ฆฟ Blade ํŒŒ์ผ ์ƒ์„ฑ:
resources/views/certificates/template.blade.php ํŒŒ์ผ์„ ๋งŒ๋“ค๊ณ  ๋‹ค์Œ ๋‚ด์šฉ์„ ์ž‘์„ฑํ•˜์„ธ์š”:

```blade
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>์ˆ˜๋ฃŒ์ฆ</title>
    <style>
        body {
            font-family: 'DejaVu Sans', sans-serif;
            text-align: center;
            padding: 50px;
        }
        .certificate {
            border: 10px solid #4F46E5;
            padding: 40px;
            margin: 0 auto;
            max-width: 800px;
        }
        h1 {
            font-size: 48px;
            margin-bottom: 20px;
            color: #4F46E5;
        }
        h2 {
            font-size: 32px;
            margin-bottom: 40px;
        }
        .content {
            font-size: 18px;
            line-height: 1.8;
            margin-bottom: 40px;
        }
        .signature {
            margin-top: 60px;
            font-size: 16px;
        }
        .footer {
            margin-top: 40px;
            font-size: 14px;
            color: #666;
        }
    </style>
</head>
<body>
    <div class="certificate">
        <h1>์ˆ˜๋ฃŒ์ฆ</h1>
        <h2>Certificate of Completion</h2>

        <div class="content">
            <p style="font-size: 24px; margin-bottom: 10px;"><strong>{{ $name }}</strong> ๋‹˜</p>
            <p>์œ„ ๋ถ„์€ ์•„๋ž˜์˜ ์›Œํฌ์ˆ์„ ์„ฑ์‹คํžˆ ์ด์ˆ˜ํ•˜์˜€๊ธฐ์— ์ด ์ฆ์„œ๋ฅผ ์ˆ˜์—ฌํ•ฉ๋‹ˆ๋‹ค.</p>
            <br>
            <p><strong>์›Œํฌ์ˆ๋ช…:</strong> {{ $workshop_title }}</p>
            <p><strong>๊ฐ•์‚ฌ:</strong> {{ $instructor }}</p>
            <p><strong>๊ต์œก ๊ธฐ๊ฐ„:</strong> {{ $start_date }} ~ {{ $end_date }}</p>
            <p><strong>์ถœ์„๋ฅ :</strong> {{ $attendance_rate }}%</p>
        </div>

        <div class="signature">
            <p>{{ $issue_date }}</p>
            <p>์›Œํฌ์ˆ ์šด์˜์ง„ ์ผ๋™</p>
        </div>

        <div class="footer">
            <p>๋ณธ ์ˆ˜๋ฃŒ์ฆ์€ ์˜จ๋ผ์ธ ์›Œํฌ์ˆ ํ”Œ๋žซํผ์—์„œ ์ž๋™ ๋ฐœ๊ธ‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค.</p>
        </div>
    </div>
</body>
</html>
```

๋‚˜์˜ ๋“ฑ๋ก ๋‚ด์—ญ ํŽ˜์ด์ง€(MyEnrollments.jsx)์—์„œ ์ˆ˜๋ฃŒ์ฆ ๋ฐœ๊ธ‰ ๋งํฌ๊ฐ€ ์ œ๋Œ€๋กœ ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”:
```jsx
<Link
    href={`/certificates/${enrollment.id}`}
    className="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 transition inline-block"
>
    ์ˆ˜๋ฃŒ์ฆ ๋ฐœ๊ธ‰๋ฐ›๊ธฐ
</Link>
```

๋ธŒ๋ผ์šฐ์ €์—์„œ ์ถœ์„๋ฅ  80% ์ด์ƒ์ธ ์›Œํฌ์ˆ์˜ ์ˆ˜๋ฃŒ์ฆ ๋ฐœ๊ธ‰ ๋ฒ„ํŠผ์„ ํด๋ฆญํ•˜๋ฉด PDF ํŒŒ์ผ์ด ๋‹ค์šด๋กœ๋“œ๋˜๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”.

๋งŒ์•ฝ PDF ์ƒ์„ฑ ์‹œ ํ•œ๊ธ€ ํฐํŠธ๊ฐ€ ๊นจ์ง„๋‹ค๋ฉด, resources/views/certificates/template.blade.php์˜ font-family๋ฅผ 'DejaVu Sans'๋กœ ์„ค์ •ํ•˜์„ธ์š” (DomPDF๊ฐ€ ๊ธฐ๋ณธ ์ง€์›ํ•˜๋Š” ํฐํŠธ).

์ตœ์ข… ํ…Œ์ŠคํŠธ:
1. ์›Œํฌ์ˆ ๋ชฉ๋ก ํŽ˜์ด์ง€์—์„œ ์นดํ…Œ๊ณ ๋ฆฌ ํ•„ํ„ฐ๊ฐ€ ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธ
2. ์›Œํฌ์ˆ ์ƒ์„ธ ํŽ˜์ด์ง€์—์„œ ๋“ฑ๋ก ํผ์ด ์ •์ƒ ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธ
3. ๋‚˜์˜ ๋“ฑ๋ก ๋‚ด์—ญ ํŽ˜์ด์ง€์—์„œ ์ถœ์„๋ฅ ์ด ์ •ํ™•ํžˆ ํ‘œ์‹œ๋˜๋Š”์ง€ ํ™•์ธ
4. ์ถœ์„ ์ฒดํฌ ํŽ˜์ด์ง€์—์„œ ์ถœ์„ ๊ธฐ๋ก์ด ์ •์ƒ ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธ
5. ์ถœ์„๋ฅ  80% ์ด์ƒ ์‹œ ์ˆ˜๋ฃŒ์ฆ PDF๊ฐ€ ์ •์ƒ ๋‹ค์šด๋กœ๋“œ๋˜๋Š”์ง€ ํ™•์ธ

๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: ์ˆ˜๋ฃŒ์ฆ ์ž๋™ ๋ฐœ๊ธ‰ ๊ธฐ๋Šฅ์ด ์™„์„ฑ๋ฉ๋‹ˆ๋‹ค. ์ถœ์„๋ฅ  80% ์ด์ƒ์ธ ์ฐธ๊ฐ€์ž๋Š” ์ˆ˜๋ฃŒ์ฆ PDF๋ฅผ ๋‹ค์šด๋กœ๋“œํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์ˆ˜๋ฃŒ์ฆ์—๋Š” ์ฐธ๊ฐ€์ž ์ด๋ฆ„, ์›Œํฌ์ˆ๋ช…, ๊ฐ•์‚ฌ, ๊ต์œก ๊ธฐ๊ฐ„, ์ถœ์„๋ฅ ์ด ํฌํ•จ๋ฉ๋‹ˆ๋‹ค. ๋ชจ๋“  ๊ธฐ๋Šฅ์ด ์ •์ƒ ์ž‘๋™ํ•˜๋Š” ์™„์„ฑ๋œ ์›Œํฌ์ˆ ๋“ฑ๋ก & ์ˆ˜๋ฃŒ์ฆ ๋ฐœ๊ธ‰ ํ”Œ๋žซํผ์ด ์™„์„ฑ๋ฉ๋‹ˆ๋‹ค.

์ œ์ถœ๋œ ์ž‘ํ’ˆ (0)

๋กœ๊ทธ์ธ ํ›„ ์ œ์ถœ

์•„์ง ์ œ์ถœ๋œ ์ž‘ํ’ˆ์ด ์—†์Šต๋‹ˆ๋‹ค. ์ฒซ ๋ฒˆ์งธ ๋„์ „์ž๊ฐ€ ๋˜์–ด๋ณด์„ธ์š”!

๋Œ“๊ธ€ (0)

๋กœ๊ทธ์ธ ํ›„ ๋Œ“๊ธ€์„ ๋‚จ๊ธธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
์•„์ง ๋Œ“๊ธ€์ด ์—†์Šต๋‹ˆ๋‹ค. ์ฒซ ๋ฒˆ์งธ ๋Œ“๊ธ€์„ ๋‚จ๊ฒจ๋ณด์„ธ์š”!