๐ธ ๋ฐด๋ ๋ฉค๋ฒ ๋ชจ์ง ๊ณต๊ณ ํ - ํฌ์ง์ ๋ณ ์ง์์ ์ ์ & ์ค๋์ ์ผ์ ๊ด๋ฆฌ
band-member-recruitment-board
๋ฌธ์ ์ค๋ช
๐ต ํ๋ก์ ํธ ๊ฐ์
๋ฐด๋๋ฅผ ๊ตฌ์ฑํ๋ ค๋ ํ์ด ๋ฉค๋ฒ๋ฅผ ๋ชจ์งํ๊ณ , ์ง์์๊ฐ ํฌ์ง์ ๋ณ๋ก ์ง์์๋ฅผ ์ ์ถํ๋ ํ๋ซํผ์ ๋๋ค. Laravel 13.x + Inertia.js + React 19 ๋ชจ๋ ธ๋ฆฌ์ค ํ์คํ์ผ๋ก ๊ตฌํํ๋ฉฐ, ์ค์ ์์ ์ฌ์์ ํ์ฉ ๊ฐ๋ฅํ ์ค์ฉ์ ์ธ ์๋น์ค์ ๋๋ค.
โจ ํ์ ๊ธฐ๋ฅ
1. ๋ฉค๋ฒ ๋ชจ์ง ๊ณต๊ณ ๊ด๋ฆฌ
- ๋ฐด๋๋ช , ์ฅ๋ฅด, ํ๋ ์ง์ญ, ๋ชจ์ง ํฌ์ง์ (๋ณด์ปฌ/๊ธฐํ/๋ฒ ์ด์ค/๋๋ผ/ํค๋ณด๋ ๋ฑ) ์ ๋ ฅ
- ๋ฐด๋ ์๊ฐ, ์งํฅํ๋ ์์ ์คํ์ผ, ์ฐ์ต ๋น๋ ๋ฑ ์์ธ ์ ๋ณด ์์ฑ
- ๋ชจ์ง ๋ง๊ฐ์ผ ์ค์ ๋ฐ ์ํ ๊ด๋ฆฌ(๋ชจ์ง์ค/๋ง๊ฐ/์ค๋์ ์งํ์ค)
- ๊ณต๊ณ ๋ณ ์กฐํ์ ์นด์ดํ
2. ํฌ์ง์ ๋ณ ์ง์์ ์ ์
- ์ง์์ ์ด๋ฆ, ์ฐ๋ฝ์ฒ, ์ง์ ํฌ์ง์ ์ ํ
- ๊ฒฝ๋ ฅ ์ฌํญ, ์๊ธฐ์๊ฐ, ์ฐ์ฃผ ๊ฐ๋ฅ ์ฅ๋ฅด ์ ๋ ฅ
- ์ ํ๋ธ/์ฌ์ด๋ํด๋ผ์ฐ๋ ๋ฑ ํฌํธํด๋ฆฌ์ค ๋งํฌ ์ฒจ๋ถ
- ์ ํธํ๋ ์ค๋์ ์ผ์ ์ ํ(๋ณต์ ์ ํ ๊ฐ๋ฅ)
3. ์ง์์ ๊ด๋ฆฌ ๋์๋ณด๋
- ๊ณต๊ณ ๋ณ ์ง์์ ๋ชฉ๋ก ์กฐํ(ํฌ์ง์ ๋ณ ํํฐ๋ง)
- ์ง์์ ์์ธ๋ณด๊ธฐ(๋ชจ๋ฌ ๋๋ ๋ณ๋ ํ์ด์ง)
- ์ง์ ์ํ ๋ณ๊ฒฝ(๊ฒํ ์ค/ํฉ๊ฒฉ/๋ถํฉ๊ฒฉ)
- ํฉ๊ฒฉ์์๊ฒ ์ค๋์ ์ผ์ ํ์ ์๋ฆผ(์ด๋ฉ์ผ ์๋ฎฌ๋ ์ด์ )
4. ๊ฒ์ ๋ฐ ํํฐ๋ง
- ์ฅ๋ฅด๋ณ, ์ง์ญ๋ณ, ํฌ์ง์ ๋ณ ๊ณต๊ณ ๊ฒ์
- ๋ง๊ฐ ์๋ฐ ๊ณต๊ณ ํ์ด๋ผ์ดํธ ํ์
- ์ต์ ์/์กฐํ์์ ์ ๋ ฌ
5. ๋ฐ์ํ UI
- Tailwind CSS ํ์ฉํ ๋ชจ๋ํ ๋์์ธ
- ๋ชจ๋ฐ์ผ/ํ๋ธ๋ฆฟ/๋ฐ์คํฌํฑ ๋์
- ํฌ์ง์ ๋ณ ์์ ๊ตฌ๋ถ(๋ณด์ปฌ=ํ๋, ๊ธฐํ=๋นจ๊ฐ ๋ฑ)
- ์ง์์ ์ ์ถ ์ ์ ๋๋ฉ์ด์ ํจ๊ณผ
๐ ์ ํ ์ถ๊ฐ ๊ธฐ๋ฅ (๋ณด๋์ค)
- ๋ฐด๋ ๋ก๊ณ ์ด๋ฏธ์ง ์ ๋ก๋ ๊ธฐ๋ฅ
- ์ง์์ ์ฆ๊ฒจ์ฐพ๊ธฐ(๋ถ๋งํฌ) ๊ธฐ๋ฅ
- ๊ณต๊ณ ๋ณ ๋๊ธ/๋ฌธ์ ๊ฒ์ํ
- ์ค๋์ ์ผ์ ์บ๋ฆฐ๋ ๋ทฐ
- ์ง์ ํต๊ณ ์ฐจํธ(ํฌ์ง์ ๋ณ ์ง์์ ์ ์๊ฐํ)
- ์ด๋ฉ์ผ ์๋ฆผ ์ค์ ์ ์ก(Mailtrap ์ฐ๋)
๐ฏ ํ์ต ๋ชฉํ
- Laravel 13.x + Inertia.js + React 19 ๋ชจ๋ ธ๋ฆฌ์ค ์ํคํ ์ฒ ์ดํด
- Eloquent ORM์ ํ์ฉํ ๋ณต์กํ ๊ด๊ณํ ๋ฐ์ดํฐ ๋ชจ๋ธ๋ง(๋ฐด๋-๊ณต๊ณ -์ง์์)
- Inertia.js์ ์๋ฒ-ํด๋ผ์ด์ธํธ ๋ฐ์ดํฐ ์ ๋ฌ ๋ฉ์ปค๋์ฆ ํ์ต
- React ์ปดํฌ๋ํธ ๋ถ๋ฆฌ ๋ฐ ์ฌ์ฌ์ฉ ํจํด ์ตํ๊ธฐ
- Form ์ ํจ์ฑ ๊ฒ์ฌ(Laravel Validation + React)
- ๋ชจ๋ฌ, ๋๋กญ๋ค์ด, ํํฐ๋ง ๋ฑ ์ค๋ฌด UI ํจํด ๊ตฌํ
- SQLite ๊ธฐ๋ฐ ๋ง์ด๊ทธ๋ ์ด์ ๋ฐ ์๋ ํ์ฉ
์ด ํ๋ก์ ํธ๋ฅผ ํตํด ์ค์ ์๋น์ค ์์ค์ ํ์คํ CRUD ์ ํ๋ฆฌ์ผ์ด์ ์ ๊ตฌ์ถํ๊ณ , Inertia.js์ ๊ฐ๋ ฅํจ์ ์ฒดํํ ์ ์์ต๋๋ค.
์ ์ฝ ์กฐ๊ฑด
- โข Laravel 13.x + Inertia.js + React 19 ๋ชจ๋ ธ๋ฆฌ์ค ๊ตฌ์กฐ๋ก ๊ตฌํ (๋ณ๋ API ๋ผ์ฐํธ ์์)
- โข npx create laravel ๋๋ Laravel Breeze(React+Inertia) starter kit ์ฌ์ฉ
- โข ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ SQLite ์ฌ์ฉ (config/database.php์์ ๊ธฐ๋ณธ๊ฐ ์ค์ )
- โข Tailwind CSS ํ์ ์ฌ์ฉ
- โข Inertia::render()๋ฅผ ํตํด Laravel ์ปจํธ๋กค๋ฌ์์ React ์ปดํฌ๋ํธ๋ก ๋ฐ์ดํฐ ์ ๋ฌ
- โข ์ต์ 3๊ฐ ์ด์์ ๋ชจ๋ธ ๊ด๊ณ ์ค์ (Band, Recruitment, Application)
- โข Form ์ ํจ์ฑ ๊ฒ์ฌ๋ Laravel Request ํด๋์ค ํ์ฉ
- โข React ์ปดํฌ๋ํธ๋ Pages/ ํด๋์ ํ์ด์ง ๋จ์๋ก, Components/ ํด๋์ ์ฌ์ฌ์ฉ ์ปดํฌ๋ํธ๋ก ๋ถ๋ฆฌ
- โข ์ค์๊ฐ ์ฑํ ๊ธฐ๋ฅ ๊ธ์ง (๋๊ธ์ HTTP ๊ธฐ๋ฐ์ผ๋ก ๊ตฌํ ๊ฐ๋ฅ)
- โข ๋ชจ๋ CRUD ์์ ์ Inertia.js์ router.post/put/delete ์ฌ์ฉ
ํ๋กฌํํธ ๋ ์ํผ
์๋ ํ๋กฌํํธ๋ฅผ ์์๋๋ก AI์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
mkdir band-member-recruitment-board && cd band-member-recruitment-board ์ด ํด๋์์ Laravel 13.x + Inertia.js + React 19 ํ๋ก์ ํธ๋ฅผ ์์ํฉ๋๋ค. # ํ๋ก์ ํธ ์ด๊ธฐ ์ค์ ๋ค์ ๋ช ๋ น์ผ๋ก Laravel Breeze (React + Inertia.js) starter kit์ ์ค์นํ์ธ์: ```bash composer create-project laravel/laravel . composer require laravel/breeze --dev php artisan breeze:install react npm install npm run dev ``` ์ค์ ์๊ตฌ์ฌํญ: 1. `.env` ํ์ผ์์ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ฅผ SQLite๋ก ์ค์ : - `DB_CONNECTION=sqlite` - `DB_DATABASE` ํญ๋ชฉ ์ฃผ์ ์ฒ๋ฆฌ ๋๋ ์ญ์ - `database/database.sqlite` ํ์ผ ์์ฑ: `touch database/database.sqlite` 2. Tailwind CSS๊ฐ Breeze์ ํจ๊ป ์๋ ์ค์น๋์๋์ง ํ์ธ 3. ํ๋ก์ ํธ ๊ตฌ์กฐ ํ์ธ: - `resources/js/Pages/` - React ํ์ด์ง ์ปดํฌ๋ํธ - `resources/js/Components/` - ์ฌ์ฌ์ฉ ์ปดํฌ๋ํธ - `app/Http/Controllers/` - Inertia ์ปจํธ๋กค๋ฌ 4. ๊ฐ๋ฐ ์๋ฒ ์คํ: - ํฐ๋ฏธ๋ 1: `php artisan serve` - ํฐ๋ฏธ๋ 2: `npm run dev` 5. ๋ธ๋ผ์ฐ์ ์์ `http://localhost:8000` ์ ์ํ์ฌ Laravel Breeze ๊ธฐ๋ณธ ํ๋ฉด(Welcome ํ์ด์ง) ํ์ธ ์๋ฃ ํ ๋ค์ ๋จ๊ณ๋ฅผ ์งํํ ์ค๋น๊ฐ ๋์๋ค๊ณ ์๋ ค์ฃผ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: Laravel 13.x + Breeze (React + Inertia.js) ํ๋ก์ ํธ๊ฐ ์์ฑ๋๊ณ , SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค๊ฐ ์ค์ ๋๋ฉฐ, ๊ฐ๋ฐ ์๋ฒ๊ฐ ์คํ๋ฉ๋๋ค. ๋ธ๋ผ์ฐ์ ์์ ๊ธฐ๋ณธ Welcome ํ์ด์ง๊ฐ ํ์๋์ด์ผ ํฉ๋๋ค.
# ๋ฐ์ดํฐ๋ฒ ์ด์ค ๋ง์ด๊ทธ๋ ์ด์
๋ฐ ๋ชจ๋ธ ์์ฑ
๋ฐด๋ ๋ฉค๋ฒ ๋ชจ์ง ๊ณต๊ณ ํ์ ํต์ฌ ๋ฐ์ดํฐ ๋ชจ๋ธ์ ์ค๊ณํฉ๋๋ค. 3๊ฐ์ ์ฃผ์ ๋ชจ๋ธ(Band, Recruitment, Application)๊ณผ ๊ด๊ณ๋ฅผ ์ค์ ํ์ธ์.
## 1. ๋ง์ด๊ทธ๋ ์ด์
ํ์ผ ์์ฑ
```bash
php artisan make:model Band -m
php artisan make:model Recruitment -m
php artisan make:model Application -m
```
## 2. ๋ง์ด๊ทธ๋ ์ด์
์คํค๋ง ์์ฑ
### `database/migrations/xxxx_create_bands_table.php`
```php
Schema::create('bands', function (Blueprint $table) {
$table->id();
$table->string('name'); // ๋ฐด๋๋ช
$table->string('genre'); // ์ฅ๋ฅด (๋ก, ์ฌ์ฆ, ํํฉ ๋ฑ)
$table->string('region'); // ํ๋ ์ง์ญ
$table->text('description'); // ๋ฐด๋ ์๊ฐ
$table->string('logo_url')->nullable(); // ๋ก๊ณ ์ด๋ฏธ์ง URL (์ ํ)
$table->timestamps();
});
```
### `database/migrations/xxxx_create_recruitments_table.php`
```php
Schema::create('recruitments', function (Blueprint $table) {
$table->id();
$table->foreignId('band_id')->constrained()->onDelete('cascade');
$table->json('positions'); // ๋ชจ์ง ํฌ์ง์
๋ฐฐ์ด (๋ณด์ปฌ, ๊ธฐํ, ๋ฒ ์ด์ค ๋ฑ)
$table->text('details'); // ์์ธ ๋ชจ์ง ๋ด์ฉ
$table->string('practice_frequency'); // ์ฐ์ต ๋น๋ (์ฃผ 1ํ, ์ฃผ 2ํ ๋ฑ)
$table->date('deadline'); // ๋ชจ์ง ๋ง๊ฐ์ผ
$table->enum('status', ['open', 'closed', 'audition'])->default('open'); // ๋ชจ์ง ์ํ
$table->integer('views')->default(0); // ์กฐํ์
$table->timestamps();
});
```
### `database/migrations/xxxx_create_applications_table.php`
```php
Schema::create('applications', function (Blueprint $table) {
$table->id();
$table->foreignId('recruitment_id')->constrained()->onDelete('cascade');
$table->string('name'); // ์ง์์ ์ด๋ฆ
$table->string('email'); // ์ฐ๋ฝ์ฒ
$table->string('phone');
$table->string('position'); // ์ง์ ํฌ์ง์
$table->text('experience'); // ๊ฒฝ๋ ฅ ์ฌํญ
$table->text('introduction'); // ์๊ธฐ์๊ฐ
$table->json('genres'); // ์ฐ์ฃผ ๊ฐ๋ฅ ์ฅ๋ฅด ๋ฐฐ์ด
$table->string('portfolio_url')->nullable(); // ํฌํธํด๋ฆฌ์ค ๋งํฌ
$table->json('preferred_dates')->nullable(); // ์ ํธ ์ค๋์
์ผ์ ๋ฐฐ์ด
$table->enum('status', ['pending', 'reviewed', 'accepted', 'rejected'])->default('pending');
$table->timestamps();
});
```
## 3. ๋ชจ๋ธ ๊ด๊ณ ์ค์
### `app/Models/Band.php`
```php
public function recruitments()
{
return $this->hasMany(Recruitment::class);
}
```
### `app/Models/Recruitment.php`
```php
protected $casts = [
'positions' => 'array',
'deadline' => 'date',
];
public function band()
{
return $this->belongsTo(Band::class);
}
public function applications()
{
return $this->hasMany(Application::class);
}
```
### `app/Models/Application.php`
```php
protected $casts = [
'genres' => 'array',
'preferred_dates' => 'array',
];
public function recruitment()
{
return $this->belongsTo(Recruitment::class);
}
```
## 4. ๋ง์ด๊ทธ๋ ์ด์
์คํ
```bash
php artisan migrate
```
## 5. ์๋ ๋ฐ์ดํฐ ์์ฑ (์ ํ)
`database/seeders/DatabaseSeeder.php`์ ์ํ ๋ฐ์ดํฐ ์ถ๊ฐ:
```php
use App\Models\Band;
use App\Models\Recruitment;
Band::factory()->create([
'name' => 'The Rolling Codes',
'genre' => '๋ก',
'region' => '์์ธ ํ๋',
'description' => '2000๋
๋ ๋ธ๋ฆฟํ์ ์งํฅํ๋ 4์ธ์กฐ ๋ฐด๋์
๋๋ค.',
]);
// Recruitment ์ํ ๋ฐ์ดํฐ๋ ์ถ๊ฐ
```
๋ง์ด๊ทธ๋ ์ด์
์ด ์ฑ๊ณต์ ์ผ๋ก ์๋ฃ๋๋ฉด ๋ค์ ๋จ๊ณ๋ก ์งํํ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: 3๊ฐ์ ๋ชจ๋ธ(Band, Recruitment, Application)๊ณผ ๋ง์ด๊ทธ๋ ์ด์ ์ด ์์ฑ๋๊ณ , ๊ด๊ณ๊ฐ ์ค์ ๋ฉ๋๋ค. `php artisan migrate` ์คํ ์ SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ํ ์ด๋ธ์ด ์์ฑ๋ฉ๋๋ค.
# ๋ชจ์ง ๊ณต๊ณ ๋ชฉ๋ก ํ์ด์ง ๊ตฌํ
๋ฉ์ธ ํ๋ฉด์์ ๋ชจ๋ ๋ชจ์ง ๊ณต๊ณ ๋ฅผ ์นด๋ ํํ๋ก ํ์ํ๊ณ , ๊ฒ์/ํํฐ๋ง ๊ธฐ๋ฅ์ ์ถ๊ฐํฉ๋๋ค.
## 1. ์ปจํธ๋กค๋ฌ ์์ฑ
```bash
php artisan make:controller RecruitmentController
```
### `app/Http/Controllers/RecruitmentController.php`
```php
use App\Models\Recruitment;
use Inertia\Inertia;
public function index(Request $request)
{
$query = Recruitment::with('band')
->where('status', 'open')
->orderBy('created_at', 'desc');
// ๊ฒ์ ํํฐ
if ($request->genre) {
$query->whereHas('band', function($q) use ($request) {
$q->where('genre', $request->genre);
});
}
if ($request->region) {
$query->whereHas('band', function($q) use ($request) {
$q->where('region', 'like', '%' . $request->region . '%');
});
}
if ($request->position) {
$query->whereJsonContains('positions', $request->position);
}
// ์ ๋ ฌ
if ($request->sort === 'views') {
$query->orderBy('views', 'desc');
}
$recruitments = $query->paginate(12);
return Inertia::render('Recruitments/Index', [
'recruitments' => $recruitments,
'filters' => $request->only(['genre', 'region', 'position', 'sort']),
]);
}
```
## 2. ๋ผ์ฐํธ ์ค์
### `routes/web.php`
```php
use App\Http\Controllers\RecruitmentController;
Route::get('/', [RecruitmentController::class, 'index'])->name('recruitments.index');
```
## 3. React ํ์ด์ง ์ปดํฌ๋ํธ ์์ฑ
### `resources/js/Pages/Recruitments/Index.jsx`
```jsx
import { Head, Link, router } from '@inertiajs/react';
import { useState } from 'react';
export default function Index({ recruitments, filters }) {
const [search, setSearch] = useState({
genre: filters.genre || '',
region: filters.region || '',
position: filters.position || '',
sort: filters.sort || 'latest',
});
const handleFilter = () => {
router.get('/', search, { preserveState: true });
};
const positions = ['๋ณด์ปฌ', '๊ธฐํ', '๋ฒ ์ด์ค', '๋๋ผ', 'ํค๋ณด๋'];
const genres = ['๋ก', '์ฌ์ฆ', 'ํํฉ', '์ธ๋', '๋ฉํ', 'R&B'];
return (
<>
<Head title="๋ฐด๋ ๋ฉค๋ฒ ๋ชจ์ง" />
<div className="min-h-screen bg-gray-50">
{/* ํค๋ */}
<header className="bg-gradient-to-r from-purple-600 to-pink-600 text-white py-12">
<div className="container mx-auto px-4">
<h1 className="text-4xl font-bold mb-2">๐ธ ๋ฐด๋ ๋ฉค๋ฒ ๋ชจ์ง</h1>
<p className="text-lg opacity-90">๋น์ ์ ์์
์ ํจ๊ป ๋ง๋ค ๋๋ฃ๋ฅผ ์ฐพ์๋ณด์ธ์</p>
</div>
</header>
{/* ํํฐ ์น์
*/}
<div className="bg-white shadow-sm border-b">
<div className="container mx-auto px-4 py-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<select
value={search.genre}
onChange={(e) => setSearch({...search, genre: e.target.value})}
className="rounded-lg border-gray-300"
>
<option value="">๋ชจ๋ ์ฅ๋ฅด</option>
{genres.map(g => <option key={g} value={g}>{g}</option>)}
</select>
<input
type="text"
placeholder="์ง์ญ ๊ฒ์"
value={search.region}
onChange={(e) => setSearch({...search, region: e.target.value})}
className="rounded-lg border-gray-300"
/>
<select
value={search.position}
onChange={(e) => setSearch({...search, position: e.target.value})}
className="rounded-lg border-gray-300"
>
<option value="">๋ชจ๋ ํฌ์ง์
</option>
{positions.map(p => <option key={p} value={p}>{p}</option>)}
</select>
<button
onClick={handleFilter}
className="bg-purple-600 text-white rounded-lg px-6 py-2 hover:bg-purple-700"
>
๊ฒ์
</button>
</div>
</div>
</div>
{/* ๊ณต๊ณ ๋ชฉ๋ก */}
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{recruitments.data.map((recruitment) => (
<Link
key={recruitment.id}
href={`/recruitments/${recruitment.id}`}
className="bg-white rounded-lg shadow-md hover:shadow-xl transition-shadow p-6 border-l-4 border-purple-500"
>
<div className="flex justify-between items-start mb-3">
<h3 className="text-xl font-bold text-gray-800">
{recruitment.band.name}
</h3>
<span className="text-sm text-gray-500">๐๏ธ {recruitment.views}</span>
</div>
<div className="flex gap-2 mb-3">
<span className="px-3 py-1 bg-purple-100 text-purple-700 rounded-full text-sm">
{recruitment.band.genre}
</span>
<span className="px-3 py-1 bg-pink-100 text-pink-700 rounded-full text-sm">
๐ {recruitment.band.region}
</span>
</div>
<div className="mb-4">
<p className="text-sm text-gray-600 mb-2">๋ชจ์ง ํฌ์ง์
:</p>
<div className="flex flex-wrap gap-2">
{recruitment.positions.map((pos, i) => (
<span key={i} className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm">
{pos}
</span>
))}
</div>
</div>
<div className="text-sm text-gray-500 flex justify-between">
<span>โฐ {recruitment.practice_frequency}</span>
<span>๐
๋ง๊ฐ: {new Date(recruitment.deadline).toLocaleDateString()}</span>
</div>
</Link>
))}
</div>
{/* ํ์ด์ง๋ค์ด์
*/}
<div className="mt-8 flex justify-center gap-2">
{recruitments.links.map((link, i) => (
<Link
key={i}
href={link.url}
className={`px-4 py-2 rounded ${
link.active ? 'bg-purple-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-100'
}`}
dangerouslySetInnerHTML={{ __html: link.label }}
/>
))}
</div>
</div>
</div>
</>
);
}
```
๋ธ๋ผ์ฐ์ ์์ ๋ฉ์ธ ํ์ด์ง๋ฅผ ํ์ธํ๊ณ , ํํฐ๋ง์ด ์๋ํ๋์ง ํ
์คํธํ์ธ์. (๋ฐ์ดํฐ๊ฐ ์๋ค๋ฉด Tinker๋ ์๋๋ก ์ํ ๋ฐ์ดํฐ๋ฅผ ์ถ๊ฐํ์ธ์.)
๊ธฐ๋ ๊ฒฐ๊ณผ: ๋ชจ์ง ๊ณต๊ณ ๋ชฉ๋ก ํ์ด์ง๊ฐ ์นด๋ ํํ๋ก ํ์๋๊ณ , ์ฅ๋ฅด/์ง์ญ/ํฌ์ง์ ๋ณ ํํฐ๋ง ๋ฐ ์ ๋ ฌ ๊ธฐ๋ฅ์ด ์๋ํฉ๋๋ค. Inertia.js์ router.get()์ ํตํด ํ์ด์ง ์๋ก๊ณ ์นจ ์์ด ํํฐ๋ง์ด ๋์ํฉ๋๋ค.
# ๊ณต๊ณ ์์ธ ํ์ด์ง ๋ฐ ์ง์์ ์ ์ถ ํผ ๊ตฌํ
ํน์ ๋ชจ์ง ๊ณต๊ณ ๋ฅผ ํด๋ฆญํ๋ฉด ์์ธ ์ ๋ณด๋ฅผ ๋ณด์ฌ์ฃผ๊ณ , ํ๋จ์ ์ง์์ ์ ์ถ ํผ์ ๋ฐฐ์นํฉ๋๋ค.
## 1. ์ปจํธ๋กค๋ฌ ๋ฉ์๋ ์ถ๊ฐ
### `app/Http/Controllers/RecruitmentController.php`
```php
public function show(Recruitment $recruitment)
{
// ์กฐํ์ ์ฆ๊ฐ
$recruitment->increment('views');
$recruitment->load('band', 'applications');
return Inertia::render('Recruitments/Show', [
'recruitment' => $recruitment,
'applicationCount' => $recruitment->applications->count(),
]);
}
```
## 2. ๋ผ์ฐํธ ์ถ๊ฐ
### `routes/web.php`
```php
Route::get('/recruitments/{recruitment}', [RecruitmentController::class, 'show'])->name('recruitments.show');
```
## 3. React ์์ธ ํ์ด์ง ์ปดํฌ๋ํธ
### `resources/js/Pages/Recruitments/Show.jsx`
```jsx
import { Head, Link, useForm } from '@inertiajs/react';
import { useState } from 'react';
export default function Show({ recruitment, applicationCount }) {
const [showForm, setShowForm] = useState(false);
const { data, setData, post, processing, errors, reset } = useForm({
name: '',
email: '',
phone: '',
position: '',
experience: '',
introduction: '',
genres: [],
portfolio_url: '',
preferred_dates: [],
});
const handleSubmit = (e) => {
e.preventDefault();
post(`/recruitments/${recruitment.id}/apply`, {
onSuccess: () => {
reset();
setShowForm(false);
alert('์ง์์๊ฐ ์ฑ๊ณต์ ์ผ๋ก ์ ์ถ๋์์ต๋๋ค!');
},
});
};
const positionColors = {
'๋ณด์ปฌ': 'bg-blue-500',
'๊ธฐํ': 'bg-red-500',
'๋ฒ ์ด์ค': 'bg-green-500',
'๋๋ผ': 'bg-yellow-500',
'ํค๋ณด๋': 'bg-purple-500',
};
return (
<>
<Head title={`${recruitment.band.name} - ๋ฉค๋ฒ ๋ชจ์ง`} />
<div className="min-h-screen bg-gray-50">
{/* ํค๋ */}
<div className="bg-gradient-to-r from-purple-600 to-pink-600 text-white py-8">
<div className="container mx-auto px-4">
<Link href="/" className="text-sm hover:underline mb-2 block">โ ๋ชฉ๋ก์ผ๋ก</Link>
<h1 className="text-3xl font-bold">{recruitment.band.name}</h1>
</div>
</div>
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* ๋ฉ์ธ ์ปจํ
์ธ */}
<div className="lg:col-span-2 space-y-6">
{/* ๋ฐด๋ ์ ๋ณด */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-2xl font-bold mb-4">๋ฐด๋ ์๊ฐ</h2>
<div className="flex gap-3 mb-4">
<span className="px-4 py-2 bg-purple-100 text-purple-700 rounded-full">
{recruitment.band.genre}
</span>
<span className="px-4 py-2 bg-pink-100 text-pink-700 rounded-full">
๐ {recruitment.band.region}
</span>
</div>
<p className="text-gray-700 leading-relaxed">{recruitment.band.description}</p>
</div>
{/* ๋ชจ์ง ์์ธ */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-2xl font-bold mb-4">๋ชจ์ง ๋ด์ฉ</h2>
<div className="mb-4">
<h3 className="font-semibold text-gray-700 mb-2">๋ชจ์ง ํฌ์ง์
</h3>
<div className="flex flex-wrap gap-3">
{recruitment.positions.map((pos, i) => (
<span
key={i}
className={`px-4 py-2 ${positionColors[pos] || 'bg-gray-500'} text-white rounded-lg font-semibold`}
>
{pos}
</span>
))}
</div>
</div>
<div className="space-y-3 text-gray-700">
<p><strong>์ฐ์ต ๋น๋:</strong> {recruitment.practice_frequency}</p>
<p><strong>๋ชจ์ง ๋ง๊ฐ:</strong> {new Date(recruitment.deadline).toLocaleDateString()}</p>
<div>
<strong>์์ธ ๋ด์ฉ:</strong>
<p className="mt-2 whitespace-pre-wrap">{recruitment.details}</p>
</div>
</div>
</div>
{/* ์ง์ ๋ฒํผ */}
{!showForm && (
<button
onClick={() => setShowForm(true)}
className="w-full bg-gradient-to-r from-purple-600 to-pink-600 text-white py-4 rounded-lg font-bold text-lg hover:opacity-90 transition"
>
๐ธ ์ง์ํ๊ธฐ
</button>
)}
{/* ์ง์์ ํผ */}
{showForm && (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-2xl font-bold mb-6">์ง์์ ์์ฑ</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-semibold mb-1">์ด๋ฆ *</label>
<input
type="text"
value={data.name}
onChange={(e) => setData('name', e.target.value)}
className="w-full rounded-lg border-gray-300"
required
/>
{errors.name && <p className="text-red-500 text-sm mt-1">{errors.name}</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold mb-1">์ด๋ฉ์ผ *</label>
<input
type="email"
value={data.email}
onChange={(e) => setData('email', e.target.value)}
className="w-full rounded-lg border-gray-300"
required
/>
</div>
<div>
<label className="block text-sm font-semibold mb-1">์ฐ๋ฝ์ฒ *</label>
<input
type="tel"
value={data.phone}
onChange={(e) => setData('phone', e.target.value)}
className="w-full rounded-lg border-gray-300"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold mb-1">์ง์ ํฌ์ง์
*</label>
<select
value={data.position}
onChange={(e) => setData('position', e.target.value)}
className="w-full rounded-lg border-gray-300"
required
>
<option value="">์ ํํ์ธ์</option>
{recruitment.positions.map((pos, i) => (
<option key={i} value={pos}>{pos}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-semibold mb-1">๊ฒฝ๋ ฅ ์ฌํญ *</label>
<textarea
value={data.experience}
onChange={(e) => setData('experience', e.target.value)}
className="w-full rounded-lg border-gray-300"
rows="3"
placeholder="์ฐ์ฃผ ๊ฒฝ๋ ฅ, ์ด์ ๋ฐด๋ ํ๋ ๋ฑ"
required
/>
</div>
<div>
<label className="block text-sm font-semibold mb-1">์๊ธฐ์๊ฐ *</label>
<textarea
value={data.introduction}
onChange={(e) => setData('introduction', e.target.value)}
className="w-full rounded-lg border-gray-300"
rows="4"
placeholder="์์
์คํ์ผ, ์งํฅ์ , ๊ฐ์ค ๋ฑ"
required
/>
</div>
<div>
<label className="block text-sm font-semibold mb-1">ํฌํธํด๋ฆฌ์ค ๋งํฌ</label>
<input
type="url"
value={data.portfolio_url}
onChange={(e) => setData('portfolio_url', e.target.value)}
className="w-full rounded-lg border-gray-300"
placeholder="์ ํ๋ธ, ์ฌ์ด๋ํด๋ผ์ฐ๋ ๋ฑ"
/>
</div>
<div className="flex gap-4">
<button
type="submit"
disabled={processing}
className="flex-1 bg-purple-600 text-white py-3 rounded-lg font-semibold hover:bg-purple-700 disabled:opacity-50"
>
{processing ? '์ ์ถ ์ค...' : '์ง์์ ์ ์ถ'}
</button>
<button
type="button"
onClick={() => setShowForm(false)}
className="px-6 py-3 border border-gray-300 rounded-lg hover:bg-gray-50"
>
์ทจ์
</button>
</div>
</form>
</div>
)}
</div>
{/* ์ฌ์ด๋๋ฐ */}
<div className="space-y-6">
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="font-bold text-lg mb-3">๐ ์ง์ ํํฉ</h3>
<p className="text-3xl font-bold text-purple-600">{applicationCount}๋ช
</p>
<p className="text-sm text-gray-500 mt-1">ํ์ฌ๊น์ง ์ง์์ ์</p>
</div>
<div className="bg-gradient-to-br from-purple-50 to-pink-50 rounded-lg p-6">
<h3 className="font-bold text-lg mb-3">๐ก ์ง์ ํ</h3>
<ul className="text-sm text-gray-700 space-y-2">
<li>โ ํฌํธํด๋ฆฌ์ค ๋งํฌ๋ฅผ ๊ผญ ์ฒจ๋ถํ์ธ์</li>
<li>โ ๋ฐด๋์ ์์
์คํ์ผ์ ๋ฏธ๋ฆฌ ํ์ธํ์ธ์</li>
<li>โ ์ง์ํ ์๊ธฐ์๊ฐ๊ฐ ์ค์ํฉ๋๋ค</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</>
);
}
```
๋ค์ ๋จ๊ณ์์ ์ง์์ ์ ์ถ ์ฒ๋ฆฌ ๋ก์ง์ ๊ตฌํํ๊ฒ ์ต๋๋ค.
๊ธฐ๋ ๊ฒฐ๊ณผ: ๊ณต๊ณ ์์ธ ํ์ด์ง๊ฐ ํ์๋๊ณ , ๋ฐด๋ ์ ๋ณด, ๋ชจ์ง ์์ธ ๋ด์ฉ, ์ง์์ ์์ฑ ํผ์ด ๋ชจ๋ ๋ ๋๋ง๋ฉ๋๋ค. '์ง์ํ๊ธฐ' ๋ฒํผ ํด๋ฆญ ์ ํผ์ด ๋ํ๋๊ณ , ์ทจ์ ๋ฒํผ์ผ๋ก ์จ๊ธธ ์ ์์ต๋๋ค.
# ์ง์์ ์ ์ถ ์ฒ๋ฆฌ ๋ฐ ์ ํจ์ฑ ๊ฒ์ฌ
์ง์์ ํผ ์ ์ถ์ ์ฒ๋ฆฌํ๊ณ , Laravel์ Form Request๋ฅผ ํ์ฉํ์ฌ ์ ํจ์ฑ ๊ฒ์ฌ๋ฅผ ๊ตฌํํฉ๋๋ค.
## 1. Form Request ์์ฑ
```bash
php artisan make:request StoreApplicationRequest
```
### `app/Http/Requests/StoreApplicationRequest.php`
```php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreApplicationRequest extends FormRequest
{
public function authorize()
{
return true;
}
public function rules()
{
return [
'name' => 'required|string|max:100',
'email' => 'required|email|max:255',
'phone' => 'required|string|max:20',
'position' => 'required|string',
'experience' => 'required|string|min:50',
'introduction' => 'required|string|min:100',
'genres' => 'nullable|array',
'portfolio_url' => 'nullable|url',
'preferred_dates' => 'nullable|array',
];
}
public function messages()
{
return [
'name.required' => '์ด๋ฆ์ ์
๋ ฅํด์ฃผ์ธ์.',
'email.required' => '์ด๋ฉ์ผ์ ์
๋ ฅํด์ฃผ์ธ์.',
'email.email' => '์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ํ์์ด ์๋๋๋ค.',
'phone.required' => '์ฐ๋ฝ์ฒ๋ฅผ ์
๋ ฅํด์ฃผ์ธ์.',
'position.required' => '์ง์ ํฌ์ง์
์ ์ ํํด์ฃผ์ธ์.',
'experience.required' => '๊ฒฝ๋ ฅ ์ฌํญ์ ์
๋ ฅํด์ฃผ์ธ์.',
'experience.min' => '๊ฒฝ๋ ฅ ์ฌํญ์ ์ต์ 50์ ์ด์ ์์ฑํด์ฃผ์ธ์.',
'introduction.required' => '์๊ธฐ์๊ฐ๋ฅผ ์
๋ ฅํด์ฃผ์ธ์.',
'introduction.min' => '์๊ธฐ์๊ฐ๋ ์ต์ 100์ ์ด์ ์์ฑํด์ฃผ์ธ์.',
'portfolio_url.url' => '์ฌ๋ฐ๋ฅธ URL ํ์์ด ์๋๋๋ค.',
];
}
}
```
## 2. Application ์ปจํธ๋กค๋ฌ ์์ฑ
```bash
php artisan make:controller ApplicationController
```
### `app/Http/Controllers/ApplicationController.php`
```php
namespace App\Http\Controllers;
use App\Models\Recruitment;
use App\Models\Application;
use App\Http\Requests\StoreApplicationRequest;
use Illuminate\Http\Request;
class ApplicationController extends Controller
{
public function store(StoreApplicationRequest $request, Recruitment $recruitment)
{
// ๋ง๊ฐ์ผ ์ฒดํฌ
if ($recruitment->deadline < now()) {
return back()->withErrors(['error' => '์ด๋ฏธ ๋ง๊ฐ๋ ๊ณต๊ณ ์
๋๋ค.']);
}
// ์ง์์ ์์ฑ
$application = $recruitment->applications()->create([
'name' => $request->name,
'email' => $request->email,
'phone' => $request->phone,
'position' => $request->position,
'experience' => $request->experience,
'introduction' => $request->introduction,
'genres' => $request->genres ?? [],
'portfolio_url' => $request->portfolio_url,
'preferred_dates' => $request->preferred_dates ?? [],
'status' => 'pending',
]);
// ์ด๋ฉ์ผ ์๋ฆผ ์๋ฎฌ๋ ์ด์
(์ค์ ๋ฐ์ก์ Mailtrap ๋ฑ์ผ๋ก ๊ตฌํ ๊ฐ๋ฅ)
\Log::info('์ง์์ ์ ์ถ ์๋ฆผ', [
'recruitment_id' => $recruitment->id,
'applicant' => $request->name,
'email' => $request->email,
]);
return redirect()->route('recruitments.show', $recruitment)
->with('success', '์ง์์๊ฐ ์ฑ๊ณต์ ์ผ๋ก ์ ์ถ๋์์ต๋๋ค! ๊ณง ์ฐ๋ฝ๋๋ฆฌ๊ฒ ์ต๋๋ค.');
}
}
```
## 3. ๋ผ์ฐํธ ์ถ๊ฐ
### `routes/web.php`
```php
use App\Http\Controllers\ApplicationController;
Route::post('/recruitments/{recruitment}/apply', [ApplicationController::class, 'store'])
->name('applications.store');
```
## 4. Show ํ์ด์ง์ ์ฑ๊ณต ๋ฉ์์ง ํ์
### `resources/js/Pages/Recruitments/Show.jsx` ์๋จ์ ์ถ๊ฐ
```jsx
import { Head, Link, useForm, usePage } from '@inertiajs/react';
export default function Show({ recruitment, applicationCount }) {
const { flash } = usePage().props;
// ... ๊ธฐ์กด ์ฝ๋ ...
return (
<>
{/* ... ๊ธฐ์กด ํค๋ ... */}
<div className="container mx-auto px-4 py-8">
{/* ์ฑ๊ณต ๋ฉ์์ง */}
{flash.success && (
<div className="bg-green-100 border border-green-400 text-green-700 px-6 py-4 rounded-lg mb-6">
โ {flash.success}
</div>
)}
{/* ์๋ฌ ๋ฉ์์ง */}
{flash.error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-6 py-4 rounded-lg mb-6">
โ {flash.error}
</div>
)}
{/* ... ๊ธฐ์กด ์ปจํ
์ธ ... */}
</div>
</>
);
}
```
## 5. ํ
์คํธ
1. ๋ธ๋ผ์ฐ์ ์์ ๊ณต๊ณ ์์ธ ํ์ด์ง ์ ์
2. '์ง์ํ๊ธฐ' ๋ฒํผ ํด๋ฆญ
3. ํผ ์์ฑ (์ ํจ์ฑ ๊ฒ์ฌ ํ
์คํธ: ์ด๋ฉ์ผ ํ์ ์ค๋ฅ, ์ต์ ๊ธ์ ์ ๋ฏธ๋ฌ ๋ฑ)
4. ์ ์ถ ํ ์ฑ๊ณต ๋ฉ์์ง ํ์ธ
5. `storage/logs/laravel.log`์์ ์ง์์ ์ ์ถ ๋ก๊ทธ ํ์ธ
๋ค์ ๋จ๊ณ์์ ์ง์์ ๊ด๋ฆฌ ๋์๋ณด๋๋ฅผ ๊ตฌํํฉ๋๋ค.
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ง์์ ์ ์ถ ํผ์ด ์ ์ ์๋ํ๊ณ , Laravel์ ์ ํจ์ฑ ๊ฒ์ฌ ๊ท์น์ ๋ฐ๋ผ ์๋ฌ ๋ฉ์์ง๊ฐ ํ์๋ฉ๋๋ค. ์ ์ถ ์ฑ๊ณต ์ ์ฑ๊ณต ๋ฉ์์ง๊ฐ ํ์๋๊ณ , ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ง์์๊ฐ ์ ์ฅ๋ฉ๋๋ค.
# ์ง์์ ๊ด๋ฆฌ ๋์๋ณด๋ ๊ตฌํ
๋ฐด๋ ์ด์์๊ฐ ์ ์ถ๋ ์ง์์๋ฅผ ํ์ธํ๊ณ , ์ํ๋ฅผ ๋ณ๊ฒฝ(๊ฒํ ์ค/ํฉ๊ฒฉ/๋ถํฉ๊ฒฉ)ํ ์ ์๋ ๊ด๋ฆฌ ํ์ด์ง๋ฅผ ๋ง๋ญ๋๋ค.
## 1. ์ปจํธ๋กค๋ฌ ๋ฉ์๋ ์ถ๊ฐ
### `app/Http/Controllers/RecruitmentController.php`
```php
public function applications(Recruitment $recruitment)
{
$recruitment->load(['band', 'applications' => function($query) {
$query->latest();
}]);
// ํฌ์ง์
๋ณ ํต๊ณ
$stats = $recruitment->applications
->groupBy('position')
->map(fn($group) => $group->count());
return Inertia::render('Recruitments/Applications', [
'recruitment' => $recruitment,
'applications' => $recruitment->applications,
'stats' => $stats,
]);
}
```
### `app/Http/Controllers/ApplicationController.php`์ ์ถ๊ฐ
```php
public function updateStatus(Request $request, Application $application)
{
$request->validate([
'status' => 'required|in:pending,reviewed,accepted,rejected',
]);
$application->update([
'status' => $request->status,
]);
return back()->with('success', '์ง์ ์ํ๊ฐ ์
๋ฐ์ดํธ๋์์ต๋๋ค.');
}
```
## 2. ๋ผ์ฐํธ ์ถ๊ฐ
### `routes/web.php`
```php
Route::get('/recruitments/{recruitment}/applications', [RecruitmentController::class, 'applications'])
->name('recruitments.applications');
Route::patch('/applications/{application}/status', [ApplicationController::class, 'updateStatus'])
->name('applications.update-status');
```
## 3. React ์ง์์ ๊ด๋ฆฌ ํ์ด์ง
### `resources/js/Pages/Recruitments/Applications.jsx`
```jsx
import { Head, Link, router, usePage } from '@inertiajs/react';
import { useState } from 'react';
export default function Applications({ recruitment, applications, stats }) {
const { flash } = usePage().props;
const [filter, setFilter] = useState('all');
const [selectedApp, setSelectedApp] = useState(null);
const statusColors = {
pending: 'bg-yellow-100 text-yellow-800',
reviewed: 'bg-blue-100 text-blue-800',
accepted: 'bg-green-100 text-green-800',
rejected: 'bg-red-100 text-red-800',
};
const statusLabels = {
pending: '๊ฒํ ๋๊ธฐ',
reviewed: '๊ฒํ ์๋ฃ',
accepted: 'โ ํฉ๊ฒฉ',
rejected: 'โ ๋ถํฉ๊ฒฉ',
};
const filteredApplications = filter === 'all'
? applications
: applications.filter(app => app.status === filter);
const handleStatusChange = (applicationId, newStatus) => {
if (confirm(`์ํ๋ฅผ "${statusLabels[newStatus]}"๋ก ๋ณ๊ฒฝํ์๊ฒ ์ต๋๊น?`)) {
router.patch(`/applications/${applicationId}/status`, {
status: newStatus,
}, {
preserveScroll: true,
});
}
};
return (
<>
<Head title={`${recruitment.band.name} - ์ง์์ ๊ด๋ฆฌ`} />
<div className="min-h-screen bg-gray-50">
{/* ํค๋ */}
<div className="bg-gradient-to-r from-purple-600 to-pink-600 text-white py-8">
<div className="container mx-auto px-4">
<Link href={`/recruitments/${recruitment.id}`} className="text-sm hover:underline mb-2 block">
โ ๊ณต๊ณ ์์ธ๋ก
</Link>
<h1 className="text-3xl font-bold">{recruitment.band.name} - ์ง์์ ๊ด๋ฆฌ</h1>
<p className="text-sm opacity-90 mt-1">์ด {applications.length}๋ช
์ ์ง์์</p>
</div>
</div>
<div className="container mx-auto px-4 py-8">
{/* ์ฑ๊ณต ๋ฉ์์ง */}
{flash.success && (
<div className="bg-green-100 border border-green-400 text-green-700 px-6 py-4 rounded-lg mb-6">
โ {flash.success}
</div>
)}
{/* ํต๊ณ ์นด๋ */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-600">์ ์ฒด ์ง์์</p>
<p className="text-3xl font-bold text-purple-600">{applications.length}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-600">๊ฒํ ๋๊ธฐ</p>
<p className="text-3xl font-bold text-yellow-600">
{applications.filter(a => a.status === 'pending').length}
</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-600">ํฉ๊ฒฉ</p>
<p className="text-3xl font-bold text-green-600">
{applications.filter(a => a.status === 'accepted').length}
</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-600">๋ถํฉ๊ฒฉ</p>
<p className="text-3xl font-bold text-red-600">
{applications.filter(a => a.status === 'rejected').length}
</p>
</div>
</div>
{/* ํฌ์ง์
๋ณ ํต๊ณ */}
<div className="bg-white rounded-lg shadow p-6 mb-8">
<h2 className="text-xl font-bold mb-4">๐ ํฌ์ง์
๋ณ ์ง์ ํํฉ</h2>
<div className="flex flex-wrap gap-4">
{Object.entries(stats).map(([position, count]) => (
<div key={position} className="px-4 py-2 bg-purple-100 text-purple-700 rounded-lg">
<span className="font-semibold">{position}</span>: {count}๋ช
</div>
))}
</div>
</div>
{/* ํํฐ ๋ฒํผ */}
<div className="bg-white rounded-lg shadow p-4 mb-6">
<div className="flex gap-2">
{['all', 'pending', 'reviewed', 'accepted', 'rejected'].map(status => (
<button
key={status}
onClick={() => setFilter(status)}
className={`px-4 py-2 rounded-lg transition ${
filter === status
? 'bg-purple-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{status === 'all' ? '์ ์ฒด' : statusLabels[status]}
</button>
))}
</div>
</div>
{/* ์ง์์ ๋ชฉ๋ก */}
<div className="space-y-4">
{filteredApplications.length === 0 ? (
<div className="bg-white rounded-lg shadow p-12 text-center text-gray-500">
์ง์์๊ฐ ์์ต๋๋ค.
</div>
) : (
filteredApplications.map((app) => (
<div key={app.id} className="bg-white rounded-lg shadow hover:shadow-lg transition p-6">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-xl font-bold text-gray-800">{app.name}</h3>
<p className="text-sm text-gray-600">
{app.email} | {app.phone}
</p>
</div>
<div className="flex gap-2 items-center">
<span className="px-3 py-1 bg-purple-100 text-purple-700 rounded-full font-semibold">
{app.position}
</span>
<span className={`px-3 py-1 rounded-full text-sm font-semibold ${statusColors[app.status]}`}>
{statusLabels[app.status]}
</span>
</div>
</div>
<div className="mb-4 space-y-2">
<div>
<p className="text-sm font-semibold text-gray-700">๊ฒฝ๋ ฅ ์ฌํญ:</p>
<p className="text-gray-600 text-sm whitespace-pre-wrap">{app.experience}</p>
</div>
<div>
<p className="text-sm font-semibold text-gray-700">์๊ธฐ์๊ฐ:</p>
<p className="text-gray-600 text-sm whitespace-pre-wrap">{app.introduction}</p>
</div>
{app.portfolio_url && (
<div>
<p className="text-sm font-semibold text-gray-700">ํฌํธํด๋ฆฌ์ค:</p>
<a
href={app.portfolio_url}
target="_blank"
rel="noopener noreferrer"
className="text-purple-600 hover:underline text-sm"
>
{app.portfolio_url}
</a>
</div>
)}
</div>
{/* ์ํ ๋ณ๊ฒฝ ๋ฒํผ */}
<div className="flex gap-2 pt-4 border-t">
<button
onClick={() => handleStatusChange(app.id, 'reviewed')}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm"
>
๊ฒํ ์๋ฃ
</button>
<button
onClick={() => handleStatusChange(app.id, 'accepted')}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 text-sm"
>
ํฉ๊ฒฉ
</button>
<button
onClick={() => handleStatusChange(app.id, 'rejected')}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 text-sm"
>
๋ถํฉ๊ฒฉ
</button>
</div>
<p className="text-xs text-gray-400 mt-3">
์ง์์ผ: {new Date(app.created_at).toLocaleDateString()}
</p>
</div>
))
)}
</div>
</div>
</div>
</>
);
}
```
## 4. Show ํ์ด์ง์ ๊ด๋ฆฌ ๋ฒํผ ์ถ๊ฐ
### `resources/js/Pages/Recruitments/Show.jsx` ์ฌ์ด๋๋ฐ์ ์ถ๊ฐ
```jsx
{/* ์ฌ์ด๋๋ฐ ๋ด๋ถ์ ์ถ๊ฐ */}
<Link
href={`/recruitments/${recruitment.id}/applications`}
className="block w-full bg-purple-600 text-white text-center py-3 rounded-lg font-semibold hover:bg-purple-700"
>
๐ ์ง์์ ๊ด๋ฆฌ
</Link>
```
๋ธ๋ผ์ฐ์ ์์ ์ง์์ ๊ด๋ฆฌ ํ์ด์ง์ ์ ์ํ์ฌ ํํฐ๋ง, ์ํ ๋ณ๊ฒฝ ๊ธฐ๋ฅ์ ํ
์คํธํ์ธ์. ์ด์ ์์ฑ๋ ํ์คํ ์ ํ๋ฆฌ์ผ์ด์
์
๋๋ค!
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ง์์ ๊ด๋ฆฌ ๋์๋ณด๋๊ฐ ํ์๋๊ณ , ํฌ์ง์ ๋ณ ํต๊ณ, ์ํ๋ณ ํํฐ๋ง, ์ง์์ ์์ธ๋ณด๊ธฐ, ์ํ ๋ณ๊ฒฝ ๋ฒํผ์ด ๋ชจ๋ ์๋ํฉ๋๋ค. Inertia.js์ preserveScroll ์ต์ ์ผ๋ก ์ํ ๋ณ๊ฒฝ ์ ์คํฌ๋กค ์์น๊ฐ ์ ์ง๋ฉ๋๋ค.
์ ์ถ๋ ์ํ (0)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!