๐จ ์ผ๋ฌ์คํธ๋ ์ดํฐ ์ปค๋ฏธ์ ์ฃผ๋ฌธ์ ํ๋ซํผ - ์๋ขฐ์ ์์ฑ๋ถํฐ ํฌํธํด๋ฆฌ์ค๊น์ง
illustration-commission-order-platform
๋ฌธ์ ์ค๋ช
๐ฏ ํ๋ก์ ํธ ๊ฐ์
์ผ๋ฌ์คํธ๋ ์ดํฐ๊ฐ ๊ณ ๊ฐ์ ์ปค๋ฏธ์ ์๋ขฐ๋ฅผ ์ฒด๊ณ์ ์ผ๋ก ๋ฐ๊ณ ๊ด๋ฆฌํ ์ ์๋ ํ์คํ ํ๋ซํผ์ ๋๋ค. ๊ณ ๊ฐ์ ์ํ๋ ์คํ์ผ, ์บ๋ฆญํฐ ํน์ง, ์ฉ๋ ๋ฑ์ ์์ธํ ์์ฑํ์ฌ ์๋ขฐ์๋ฅผ ์ ์ถํ๊ณ , ์ผ๋ฌ์คํธ๋ ์ดํฐ๋ ์๋ขฐ ํํฉ์ ํ์ธํ๊ณ ์ํ๋ฅผ ์ ๋ฐ์ดํธํฉ๋๋ค. ์์ฑ๋ ์ํ์ ํฌํธํด๋ฆฌ์ค ๊ฐค๋ฌ๋ฆฌ์ ์๋์ผ๋ก ์ถ๊ฐ๋์ด ์ ๊ท ๊ณ ๊ฐ ์ ์น์ ํ์ฉ๋ฉ๋๋ค.
โจ ํ์ ๊ธฐ๋ฅ
- ์ปค๋ฏธ์ ์๋ขฐ์ ์์ฑ ํผ: ๊ณ ๊ฐ์ด ์์ฑ ๋ชฉ์ (ํ๋กํ, SNS, ๊ตฟ์ฆ ๋ฑ), ์บ๋ฆญํฐ ์ค๋ช , ๋ฐฐ๊ฒฝ ์์ฒญ์ฌํญ, ์ฐธ๊ณ ์ด๋ฏธ์ง URL, ์์ฐ ๋ฒ์๋ฅผ ์ ๋ ฅ
- ์๋ขฐ ๋ชฉ๋ก ํ์ด์ง: ์ ์ถ๋ ๋ชจ๋ ์๋ขฐ๋ฅผ ์นด๋ ํํ๋ก ๋์ด, ์ํ๋ณ ํํฐ๋ง(๋๊ธฐ์ค/์งํ์ค/์๋ฃ)
- ์๋ขฐ ์์ธ ํ์ด์ง: ๊ณ ๊ฐ์ด ์์ฑํ ๋ชจ๋ ์ ๋ณด๋ฅผ ํ์ธํ๊ณ , ์ผ๋ฌ์คํธ๋ ์ดํฐ๊ฐ ์ํ๋ฅผ ๋ณ๊ฒฝํ ์ ์๋ ๋๋กญ๋ค์ด ์ ๊ณต
- ํฌํธํด๋ฆฌ์ค ๊ฐค๋ฌ๋ฆฌ: ์๋ฃ๋ ์ปค๋ฏธ์ ์ํ์ ์ธ๋ค์ผ ๊ทธ๋ฆฌ๋๋ก ํ์, ํด๋ฆญ ์ ๋ชจ๋ฌ๋ก ์์ธ ์ ๋ณด ํ์ธ
- ๋ฐ์ํ ๋์์ธ: Tailwind CSS๋ก ๋ชจ๋ฐ์ผ/๋ฐ์คํฌํ ๋ชจ๋ ์ต์ ํ
- ๋ถ๋๋ฌ์ด ์ ๋๋ฉ์ด์ : ์นด๋ hover ํจ๊ณผ, ๋ชจ๋ฌ fade-in, ์ํ ๋ณ๊ฒฝ ์ ํ ์คํธ ์๋ฆผ
๐ ๋ณด๋์ค ๊ธฐ๋ฅ
- ์๋ขฐ์ ์์ฑ ์ ์ค์๊ฐ ๊ธ์ ์ ์นด์ดํฐ
- ํฌํธํด๋ฆฌ์ค ๊ฐค๋ฌ๋ฆฌ์ ํ๊ทธ ํํฐ๋ง(์บ๋ฆญํฐ, ๋ฐฐ๊ฒฝ, ์คํ์ผ ๋ฑ)
- ์๋ฃ๋ ์ํ์ ๊ณ ๊ฐ ํ๊ธฐ ๋จ๊ธฐ๊ธฐ ๊ธฐ๋ฅ
- ์๋ขฐ ๋ชฉ๋ก ํ์ด์ง๋ค์ด์ ๋๋ ๋ฌดํ ์คํฌ๋กค
- ๋คํฌ ๋ชจ๋ ํ ๊ธ
๐ ๏ธ ๊ธฐ์ ์คํ
- Backend: Laravel 13.x (์ปจํธ๋กค๋ฌ, Eloquent ORM, ๋ง์ด๊ทธ๋ ์ด์ )
- Frontend: React 19 (ํจ์ํ ์ปดํฌ๋ํธ, hooks)
- Bridge: Inertia.js (SPA ๊ฒฝํ ์ ๊ณต, ๋ณ๋ API ๋ถํ์)
- Database: SQLite (๊ฐ๋ฐ ํธ์์ฑ)
- Styling: Tailwind CSS 3.x
- Validation: Laravel Form Requests
๐ ํ์ต ํฌ์ธํธ
- Laravel ์ปจํธ๋กค๋ฌ์์
Inertia::render()๋ก React ์ปดํฌ๋ํธ์ ๋ฐ์ดํฐ ์ ๋ฌํ๋ ๋ฐฉ๋ฒ - Eloquent ๊ด๊ณ ์ ์ ์์ด ๋จ์ผ ๋ชจ๋ธ๋ก CRUD ๊ตฌํ
- React์์
useFormhook์ผ๋ก ํผ ์ํ ๊ด๋ฆฌ ๋ฐ ์ ์ถ - Tailwind๋ก ์นด๋ ๋ ์ด์์, ๋ชจ๋ฌ, ํ ์คํธ ์๋ฆผ ๊ตฌํ
- ํ์ผ ์ ๋ก๋ ์์ด URL ๊ธฐ๋ฐ ์ด๋ฏธ์ง ์ฐธ์กฐ๋ก ๊ฐ์ํ
์ ์ฝ ์กฐ๊ฑด
- โข Laravel 13.x + Inertia.js + React 19 ๋ชจ๋ ธ๋ฆฌ์ค ํ์คํ ๊ตฌ์กฐ (๋ณ๋ API ์๋ํฌ์ธํธ ๊ธ์ง)
- โข Laravel Breeze (React + Inertia) starter kit ๋๋ `npx create laravel` ์ฌ์ฉ
- โข SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฌ์ฉ (MySQL/PostgreSQL ๊ธ์ง)
- โข Tailwind CSS 3.x ์ด์ ์ฌ์ฉ (Bootstrap ๊ธ์ง)
- โข ํ์ผ ์ ๋ก๋ ๊ธ์ง (์ด๋ฏธ์ง๋ ์ธ๋ถ URL ์ฐธ์กฐ๋ก ๋์ฒด)
- โข ์ค์๊ฐ ์ฑํ /WebSocket ๊ธ์ง (HTTP ๊ธฐ๋ฐ CRUD๋ง ์ฌ์ฉ)
- โข ๋ณต์กํ ์ํ๊ด๋ฆฌ ๋ผ์ด๋ธ๋ฌ๋ฆฌ(Redux, Zustand ๋ฑ) ๊ธ์ง (useState/useReducer๋ง ์ฌ์ฉ)
- โข ์ธ์ฆ/๋ก๊ทธ์ธ ๊ธฐ๋ฅ ์ ์ธ (์ปค๋ฏธ์ ์๋ขฐ๋ ๋๊ตฌ๋ ์์ฑ ๊ฐ๋ฅ)
- โข ์ธ๋ถ API ์ฐ๋ ๊ธ์ง
- โข ๋ชจ๋ ํ ์คํธ๋ ํ๊ตญ์ด๋ก ์์ฑ
ํ๋กฌํํธ ๋ ์ํผ
์๋ ํ๋กฌํํธ๋ฅผ ์์๋๋ก AI์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
mkdir illustration-commission-order-platform && cd illustration-commission-order-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 build ``` ํ๋ก์ ํธ ์ด๊ธฐ ์ค์ ์ ์๋ฃํ ํ, `.env` ํ์ผ์์ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ฅผ SQLite๋ก ๋ณ๊ฒฝํ์ธ์: ``` DB_CONNECTION=sqlite # DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD ์ฃผ์ ์ฒ๋ฆฌ ``` `database/database.sqlite` ํ์ผ์ ์์ฑํ๊ณ ๋ง์ด๊ทธ๋ ์ด์ ์ ์คํํ์ธ์: ```bash touch database/database.sqlite php artisan migrate ``` Tailwind CSS ์ค์ ์ ์ด๋ฏธ ํฌํจ๋์ด ์์ผ๋ฏ๋ก ์ถ๊ฐ ์ค์ ๋ถํ์ํฉ๋๋ค. ๊ฐ๋ฐ ์๋ฒ๋ฅผ ์คํํ์ฌ ๊ธฐ๋ณธ ํ๋ฉด์ด ์ ์์ ์ผ๋ก ํ์๋๋์ง ํ์ธํ์ธ์: ```bash php artisan serve npm run dev ``` ๋ธ๋ผ์ฐ์ ์์ `http://localhost:8000`์ ์ ์ํ์ฌ Laravel Breeze ๊ธฐ๋ณธ ํ์ด์ง๊ฐ ํ์๋๋ฉด ์ฑ๊ณต์ ๋๋ค.
๊ธฐ๋ ๊ฒฐ๊ณผ: Laravel 13.x + Inertia.js + React 19 ํ๋ก์ ํธ๊ฐ ์์ฑ๋๊ณ , SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค๊ฐ ์ค์ ๋๋ฉฐ, Tailwind CSS๊ฐ ์ ์ฉ๋ ๊ธฐ๋ณธ ํ์ด์ง๊ฐ ๋ธ๋ผ์ฐ์ ์ ํ์๋ฉ๋๋ค.
์ปค๋ฏธ์
์๋ขฐ์๋ฅผ ์ ์ฅํ `commissions` ํ
์ด๋ธ์ ์์ฑํฉ๋๋ค.
๋ค์ ๋ช
๋ น์ด๋ก ๋ง์ด๊ทธ๋ ์ด์
ํ์ผ์ ์์ฑํ์ธ์:
```bash
php artisan make:migration create_commissions_table
```
์์ฑ๋ ๋ง์ด๊ทธ๋ ์ด์
ํ์ผ(`database/migrations/YYYY_MM_DD_HHMMSS_create_commissions_table.php`)์ ์ด๊ณ ๋ค์๊ณผ ๊ฐ์ด ์์ฑํ์ธ์:
```php
public function up()
{
Schema::create('commissions', function (Blueprint $table) {
$table->id();
$table->string('client_name'); // ๊ณ ๊ฐ ์ด๋ฆ
$table->string('client_email'); // ๊ณ ๊ฐ ์ด๋ฉ์ผ
$table->string('purpose'); // ์์ฑ ๋ชฉ์ (ํ๋กํ, SNS, ๊ตฟ์ฆ ๋ฑ)
$table->text('character_description'); // ์บ๋ฆญํฐ ์ค๋ช
$table->text('background_request')->nullable(); // ๋ฐฐ๊ฒฝ ์์ฒญ์ฌํญ
$table->string('reference_image_url')->nullable(); // ์ฐธ๊ณ ์ด๋ฏธ์ง URL
$table->string('budget_range'); // ์์ฐ ๋ฒ์ (์: 10-20๋ง์)
$table->enum('status', ['pending', 'in_progress', 'completed'])->default('pending'); // ์ํ
$table->string('completed_image_url')->nullable(); // ์์ฑ ์ด๋ฏธ์ง URL (์๋ฃ ์)
$table->timestamps();
});
}
```
๋ง์ด๊ทธ๋ ์ด์
์ ์คํํ์ธ์:
```bash
php artisan migrate
```
๋ค์์ผ๋ก Commission ๋ชจ๋ธ์ ์์ฑํ์ธ์:
```bash
php artisan make:model Commission
```
`app/Models/Commission.php`๋ฅผ ์ด๊ณ fillable ํ๋๋ฅผ ์ ์ํ์ธ์:
```php
protected $fillable = [
'client_name',
'client_email',
'purpose',
'character_description',
'background_request',
'reference_image_url',
'budget_range',
'status',
'completed_image_url',
];
```
๊ธฐ๋ ๊ฒฐ๊ณผ: `commissions` ํ ์ด๋ธ์ด ์์ฑ๋๊ณ , Commission ๋ชจ๋ธ์ด ์ ์๋ฉ๋๋ค. ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ํ ์ด๋ธ์ด ์ ์์ ์ผ๋ก ์ถ๊ฐ๋์๋์ง ํ์ธํ์ธ์.
์ปค๋ฏธ์
์๋ขฐ์ ์์ฑ ํผ์ ๊ตฌํํฉ๋๋ค.
๋จผ์ CommissionController๋ฅผ ์์ฑํ์ธ์:
```bash
php artisan make:controller CommissionController
```
`app/Http/Controllers/CommissionController.php`๋ฅผ ์ด๊ณ ๋ค์ ๋ฉ์๋๋ฅผ ์์ฑํ์ธ์:
```php
use App\Models\Commission;
use Inertia\Inertia;
use Illuminate\Http\Request;
public function create()
{
return Inertia::render('Commissions/Create');
}
public function store(Request $request)
{
$validated = $request->validate([
'client_name' => 'required|string|max:255',
'client_email' => 'required|email',
'purpose' => 'required|string|max:255',
'character_description' => 'required|string',
'background_request' => 'nullable|string',
'reference_image_url' => 'nullable|url',
'budget_range' => 'required|string|max:255',
]);
Commission::create($validated);
return redirect()->route('commissions.index')->with('success', '์๋ขฐ์๊ฐ ์ฑ๊ณต์ ์ผ๋ก ์ ์ถ๋์์ต๋๋ค!');
}
```
`routes/web.php`์ ๋ผ์ฐํธ๋ฅผ ์ถ๊ฐํ์ธ์:
```php
use App\Http\Controllers\CommissionController;
Route::get('/commissions/create', [CommissionController::class, 'create'])->name('commissions.create');
Route::post('/commissions', [CommissionController::class, 'store'])->name('commissions.store');
```
์ด์ React ์ปดํฌ๋ํธ๋ฅผ ์์ฑํฉ๋๋ค. `resources/js/Pages/Commissions/Create.jsx` ํ์ผ์ ๋ง๋ค๊ณ ๋ค์๊ณผ ๊ฐ์ด ์์ฑํ์ธ์:
```jsx
import { useForm } from '@inertiajs/react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
export default function Create() {
const { data, setData, post, processing, errors } = useForm({
client_name: '',
client_email: '',
purpose: '',
character_description: '',
background_request: '',
reference_image_url: '',
budget_range: '',
});
const handleSubmit = (e) => {
e.preventDefault();
post(route('commissions.store'));
};
return (
<AuthenticatedLayout>
<div className="max-w-2xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">์ปค๋ฏธ์
์๋ขฐ์ ์์ฑ</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">์ด๋ฆ</label>
<input
type="text"
value={data.client_name}
onChange={(e) => setData('client_name', e.target.value)}
className="w-full border rounded px-3 py-2"
/>
{errors.client_name && <p className="text-red-500 text-sm">{errors.client_name}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-1">์ด๋ฉ์ผ</label>
<input
type="email"
value={data.client_email}
onChange={(e) => setData('client_email', e.target.value)}
className="w-full border rounded px-3 py-2"
/>
{errors.client_email && <p className="text-red-500 text-sm">{errors.client_email}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-1">์์ฑ ๋ชฉ์ </label>
<select
value={data.purpose}
onChange={(e) => setData('purpose', e.target.value)}
className="w-full border rounded px-3 py-2"
>
<option value="">์ ํํ์ธ์</option>
<option value="ํ๋กํ">ํ๋กํ</option>
<option value="SNS">SNS</option>
<option value="๊ตฟ์ฆ">๊ตฟ์ฆ</option>
<option value="๊ธฐํ">๊ธฐํ</option>
</select>
{errors.purpose && <p className="text-red-500 text-sm">{errors.purpose}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-1">์บ๋ฆญํฐ ์ค๋ช
</label>
<textarea
value={data.character_description}
onChange={(e) => setData('character_description', e.target.value)}
className="w-full border rounded px-3 py-2 h-32"
placeholder="์บ๋ฆญํฐ์ ์ธ๋ชจ, ์ฑ๊ฒฉ, ํฌ์ฆ ๋ฑ์ ์์ธํ ์์ฑํด์ฃผ์ธ์"
/>
{errors.character_description && <p className="text-red-500 text-sm">{errors.character_description}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-1">๋ฐฐ๊ฒฝ ์์ฒญ์ฌํญ (์ ํ)</label>
<textarea
value={data.background_request}
onChange={(e) => setData('background_request', e.target.value)}
className="w-full border rounded px-3 py-2 h-20"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">์ฐธ๊ณ ์ด๋ฏธ์ง URL (์ ํ)</label>
<input
type="url"
value={data.reference_image_url}
onChange={(e) => setData('reference_image_url', e.target.value)}
className="w-full border rounded px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">์์ฐ ๋ฒ์</label>
<input
type="text"
value={data.budget_range}
onChange={(e) => setData('budget_range', e.target.value)}
className="w-full border rounded px-3 py-2"
placeholder="์: 10-20๋ง์"
/>
{errors.budget_range && <p className="text-red-500 text-sm">{errors.budget_range}</p>}
</div>
<button
type="submit"
disabled={processing}
className="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600 transition"
>
์๋ขฐ์ ์ ์ถ
</button>
</form>
</div>
</AuthenticatedLayout>
);
}
```
๋ธ๋ผ์ฐ์ ์์ `/commissions/create`์ ์ ์ํ์ฌ ํผ์ด ์ ์์ ์ผ๋ก ํ์๋๊ณ ์ ์ถ๋๋์ง ํ์ธํ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ปค๋ฏธ์ ์๋ขฐ์ ์์ฑ ํผ์ด ํ์๋๊ณ , ๋ชจ๋ ํ๋๋ฅผ ์ ๋ ฅํ ํ ์ ์ถํ๋ฉด ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ ์ฅ๋ฉ๋๋ค. ์ ํจ์ฑ ๊ฒ์ฌ ์๋ฌ๊ฐ ๋ฐ์ํ๋ฉด ํผ ์๋์ ๋นจ๊ฐ์์ผ๋ก ํ์๋ฉ๋๋ค.
์ ์ถ๋ ์ปค๋ฏธ์
์๋ขฐ ๋ชฉ๋ก ํ์ด์ง๋ฅผ ๊ตฌํํฉ๋๋ค.
`CommissionController`์ `index` ๋ฉ์๋๋ฅผ ์ถ๊ฐํ์ธ์:
```php
public function index(Request $request)
{
$status = $request->query('status', 'all');
$query = Commission::query()->orderBy('created_at', 'desc');
if ($status !== 'all') {
$query->where('status', $status);
}
$commissions = $query->paginate(12);
return Inertia::render('Commissions/Index', [
'commissions' => $commissions,
'currentStatus' => $status,
]);
}
```
`routes/web.php`์ ๋ผ์ฐํธ๋ฅผ ์ถ๊ฐํ์ธ์:
```php
Route::get('/commissions', [CommissionController::class, 'index'])->name('commissions.index');
```
`resources/js/Pages/Commissions/Index.jsx` ํ์ผ์ ๋ง๋ค๊ณ ๋ค์๊ณผ ๊ฐ์ด ์์ฑํ์ธ์:
```jsx
import { Link } from '@inertiajs/react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
export default function Index({ commissions, currentStatus }) {
const statusLabels = {
all: '์ ์ฒด',
pending: '๋๊ธฐ์ค',
in_progress: '์งํ์ค',
completed: '์๋ฃ',
};
const statusColors = {
pending: 'bg-yellow-100 text-yellow-800',
in_progress: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
};
return (
<AuthenticatedLayout>
<div className="max-w-6xl mx-auto p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">์ปค๋ฏธ์
์๋ขฐ ๋ชฉ๋ก</h1>
<Link
href={route('commissions.create')}
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition"
>
์ ์๋ขฐ ์์ฑ
</Link>
</div>
<div className="flex gap-2 mb-6">
{Object.entries(statusLabels).map(([key, label]) => (
<Link
key={key}
href={route('commissions.index', { status: key })}
className={`px-4 py-2 rounded transition ${
currentStatus === key
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300'
}`}
>
{label}
</Link>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{commissions.data.map((commission) => (
<Link
key={commission.id}
href={route('commissions.show', commission.id)}
className="border rounded-lg p-4 hover:shadow-lg transition transform hover:-translate-y-1"
>
<div className="flex justify-between items-start mb-2">
<h3 className="font-bold text-lg">{commission.client_name}</h3>
<span className={`px-2 py-1 rounded text-xs font-semibold ${statusColors[commission.status]}`}>
{statusLabels[commission.status]}
</span>
</div>
<p className="text-sm text-gray-600 mb-2">{commission.purpose}</p>
<p className="text-sm text-gray-700 line-clamp-3">{commission.character_description}</p>
<p className="text-xs text-gray-500 mt-2">์์ฐ: {commission.budget_range}</p>
</Link>
))}
</div>
{commissions.links.length > 3 && (
<div className="flex justify-center gap-2 mt-6">
{commissions.links.map((link, index) => (
<Link
key={index}
href={link.url || '#'}
className={`px-3 py-1 rounded ${
link.active
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300'
} ${!link.url && 'opacity-50 cursor-not-allowed'}`}
dangerouslySetInnerHTML={{ __html: link.label }}
/>
))}
</div>
)}
</div>
</AuthenticatedLayout>
);
}
```
๋ธ๋ผ์ฐ์ ์์ `/commissions`์ ์ ์ํ์ฌ ์๋ขฐ ๋ชฉ๋ก์ด ์นด๋ ํํ๋ก ํ์๋๊ณ , ์ํ๋ณ ํํฐ๋ง์ด ์๋ํ๋์ง ํ์ธํ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ปค๋ฏธ์ ์๋ขฐ ๋ชฉ๋ก์ด ์นด๋ ๊ทธ๋ฆฌ๋๋ก ํ์๋๋ฉฐ, ์ํ๋ณ ํํฐ๋ง ๋ฒํผ์ด ์๋ํฉ๋๋ค. ์นด๋์ hover ์ ๊ทธ๋ฆผ์์ ์ฝ๊ฐ์ ์์น ์ ๋๋ฉ์ด์ ์ด ์ ์ฉ๋ฉ๋๋ค. ํ์ด์ง๋ค์ด์ ๋งํฌ๊ฐ ํ๋จ์ ํ์๋ฉ๋๋ค.
์ปค๋ฏธ์
์๋ขฐ ์์ธ ํ์ด์ง๋ฅผ ๊ตฌํํ๊ณ , ์ผ๋ฌ์คํธ๋ ์ดํฐ๊ฐ ์ํ๋ฅผ ๋ณ๊ฒฝํ ์ ์๋๋ก ํฉ๋๋ค.
`CommissionController`์ `show`์ `update` ๋ฉ์๋๋ฅผ ์ถ๊ฐํ์ธ์:
```php
public function show(Commission $commission)
{
return Inertia::render('Commissions/Show', [
'commission' => $commission,
]);
}
public function update(Request $request, Commission $commission)
{
$validated = $request->validate([
'status' => 'required|in:pending,in_progress,completed',
'completed_image_url' => 'nullable|url',
]);
$commission->update($validated);
return redirect()->route('commissions.show', $commission->id)
->with('success', '์ํ๊ฐ ์
๋ฐ์ดํธ๋์์ต๋๋ค!');
}
```
`routes/web.php`์ ๋ผ์ฐํธ๋ฅผ ์ถ๊ฐํ์ธ์:
```php
Route::get('/commissions/{commission}', [CommissionController::class, 'show'])->name('commissions.show');
Route::patch('/commissions/{commission}', [CommissionController::class, 'update'])->name('commissions.update');
```
`resources/js/Pages/Commissions/Show.jsx` ํ์ผ์ ๋ง๋ค๊ณ ๋ค์๊ณผ ๊ฐ์ด ์์ฑํ์ธ์:
```jsx
import { useForm } from '@inertiajs/react';
import { useState } from 'react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
export default function Show({ commission }) {
const { data, setData, patch, processing } = useForm({
status: commission.status,
completed_image_url: commission.completed_image_url || '',
});
const [showToast, setShowToast] = useState(false);
const handleSubmit = (e) => {
e.preventDefault();
patch(route('commissions.update', commission.id), {
onSuccess: () => {
setShowToast(true);
setTimeout(() => setShowToast(false), 3000);
},
});
};
const statusLabels = {
pending: '๋๊ธฐ์ค',
in_progress: '์งํ์ค',
completed: '์๋ฃ',
};
return (
<AuthenticatedLayout>
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">์ปค๋ฏธ์
์๋ขฐ ์์ธ</h1>
<div className="bg-white border rounded-lg p-6 mb-6">
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<p className="text-sm text-gray-500">์๋ขฐ์</p>
<p className="font-semibold">{commission.client_name}</p>
</div>
<div>
<p className="text-sm text-gray-500">์ด๋ฉ์ผ</p>
<p className="font-semibold">{commission.client_email}</p>
</div>
<div>
<p className="text-sm text-gray-500">์์ฑ ๋ชฉ์ </p>
<p className="font-semibold">{commission.purpose}</p>
</div>
<div>
<p className="text-sm text-gray-500">์์ฐ ๋ฒ์</p>
<p className="font-semibold">{commission.budget_range}</p>
</div>
</div>
<div className="mb-4">
<p className="text-sm text-gray-500 mb-1">์บ๋ฆญํฐ ์ค๋ช
</p>
<p className="whitespace-pre-wrap">{commission.character_description}</p>
</div>
{commission.background_request && (
<div className="mb-4">
<p className="text-sm text-gray-500 mb-1">๋ฐฐ๊ฒฝ ์์ฒญ์ฌํญ</p>
<p className="whitespace-pre-wrap">{commission.background_request}</p>
</div>
)}
{commission.reference_image_url && (
<div className="mb-4">
<p className="text-sm text-gray-500 mb-1">์ฐธ๊ณ ์ด๋ฏธ์ง</p>
<img
src={commission.reference_image_url}
alt="์ฐธ๊ณ ์ด๋ฏธ์ง"
className="max-w-md rounded border"
/>
</div>
)}
</div>
<div className="bg-white border rounded-lg p-6">
<h2 className="text-xl font-bold mb-4">์ํ ๊ด๋ฆฌ</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">์ํ</label>
<select
value={data.status}
onChange={(e) => setData('status', e.target.value)}
className="w-full border rounded px-3 py-2"
>
{Object.entries(statusLabels).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</div>
{data.status === 'completed' && (
<div>
<label className="block text-sm font-medium mb-1">์์ฑ ์ด๋ฏธ์ง URL</label>
<input
type="url"
value={data.completed_image_url}
onChange={(e) => setData('completed_image_url', e.target.value)}
className="w-full border rounded px-3 py-2"
placeholder="์์ฑ๋ ์ํ ์ด๋ฏธ์ง URL์ ์
๋ ฅํ์ธ์"
/>
</div>
)}
<button
type="submit"
disabled={processing}
className="bg-blue-500 text-white px-6 py-2 rounded hover:bg-blue-600 transition"
>
์ํ ์
๋ฐ์ดํธ
</button>
</form>
</div>
{showToast && (
<div className="fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded shadow-lg animate-fade-in">
โ ์ํ๊ฐ ์
๋ฐ์ดํธ๋์์ต๋๋ค!
</div>
)}
</div>
</AuthenticatedLayout>
);
}
```
`resources/css/app.css`์ ํ ์คํธ ์ ๋๋ฉ์ด์
์ ์ถ๊ฐํ์ธ์:
```css
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
```
๋ธ๋ผ์ฐ์ ์์ ์๋ขฐ ๋ชฉ๋ก์์ ์นด๋๋ฅผ ํด๋ฆญํ์ฌ ์์ธ ํ์ด์ง๋ก ์ด๋ํ๊ณ , ์ํ๋ฅผ ๋ณ๊ฒฝํ ํ ์ ์ฅํ๋ฉด ํ ์คํธ ์๋ฆผ์ด ํ์๋๋์ง ํ์ธํ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ปค๋ฏธ์ ์๋ขฐ ์์ธ ์ ๋ณด๊ฐ ํ์๋๊ณ , ์ํ ๋๋กญ๋ค์ด์์ ์ ํ ํ ์ ๋ฐ์ดํธํ๋ฉด ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ๋ฐ์๋ฉ๋๋ค. ์ํ๊ฐ '์๋ฃ'์ผ ๋๋ง ์์ฑ ์ด๋ฏธ์ง URL ์ ๋ ฅ ํ๋๊ฐ ํ์๋ฉ๋๋ค. ์ ๋ฐ์ดํธ ์ฑ๊ณต ์ ์ฐ์ธก ํ๋จ์ ์ด๋ก์ ํ ์คํธ ์๋ฆผ์ด ์ ๋๋ฉ์ด์ ๊ณผ ํจ๊ป ๋ํ๋ฉ๋๋ค.
์๋ฃ๋ ์ปค๋ฏธ์
์ํ์ ํฌํธํด๋ฆฌ์ค ๊ฐค๋ฌ๋ฆฌ๋ก ํ์ํ๋ ํ์ด์ง๋ฅผ ๊ตฌํํฉ๋๋ค.
`CommissionController`์ `portfolio` ๋ฉ์๋๋ฅผ ์ถ๊ฐํ์ธ์:
```php
public function portfolio()
{
$completedCommissions = Commission::where('status', 'completed')
->whereNotNull('completed_image_url')
->orderBy('updated_at', 'desc')
->get();
return Inertia::render('Commissions/Portfolio', [
'commissions' => $completedCommissions,
]);
}
```
`routes/web.php`์ ๋ผ์ฐํธ๋ฅผ ์ถ๊ฐํ์ธ์:
```php
Route::get('/portfolio', [CommissionController::class, 'portfolio'])->name('commissions.portfolio');
```
`resources/js/Pages/Commissions/Portfolio.jsx` ํ์ผ์ ๋ง๋ค๊ณ ๋ค์๊ณผ ๊ฐ์ด ์์ฑํ์ธ์:
```jsx
import { useState } from 'react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
export default function Portfolio({ commissions }) {
const [selectedCommission, setSelectedCommission] = useState(null);
return (
<AuthenticatedLayout>
<div className="max-w-6xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">ํฌํธํด๋ฆฌ์ค ๊ฐค๋ฌ๋ฆฌ</h1>
{commissions.length === 0 ? (
<div className="text-center text-gray-500 py-12">
<p className="text-lg">์์ง ์๋ฃ๋ ์ํ์ด ์์ต๋๋ค.</p>
<p className="text-sm mt-2">์ปค๋ฏธ์
์๋ขฐ๋ฅผ ์๋ฃํ๊ณ ํฌํธํด๋ฆฌ์ค์ ์ถ๊ฐํ์ธ์!</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{commissions.map((commission) => (
<div
key={commission.id}
onClick={() => setSelectedCommission(commission)}
className="cursor-pointer group overflow-hidden rounded-lg border hover:shadow-xl transition transform hover:scale-105"
>
<img
src={commission.completed_image_url}
alt={commission.purpose}
className="w-full h-64 object-cover group-hover:opacity-90 transition"
/>
<div className="p-2 bg-white">
<p className="text-sm font-semibold truncate">{commission.purpose}</p>
<p className="text-xs text-gray-500 truncate">{commission.client_name}</p>
</div>
</div>
))}
</div>
)}
{selectedCommission && (
<div
className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50 p-4 animate-fade-in"
onClick={() => setSelectedCommission(null)}
>
<div
className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="relative">
<button
onClick={() => setSelectedCommission(null)}
className="absolute top-4 right-4 bg-white rounded-full p-2 shadow-lg hover:bg-gray-100 transition"
>
โ
</button>
<img
src={selectedCommission.completed_image_url}
alt={selectedCommission.purpose}
className="w-full max-h-[60vh] object-contain"
/>
</div>
<div className="p-6">
<h2 className="text-2xl font-bold mb-2">{selectedCommission.purpose}</h2>
<p className="text-gray-600 mb-4">์๋ขฐ์: {selectedCommission.client_name}</p>
<div className="mb-4">
<p className="text-sm text-gray-500 mb-1">์บ๋ฆญํฐ ์ค๋ช
</p>
<p className="whitespace-pre-wrap">{selectedCommission.character_description}</p>
</div>
{selectedCommission.background_request && (
<div>
<p className="text-sm text-gray-500 mb-1">๋ฐฐ๊ฒฝ ์์ฒญ์ฌํญ</p>
<p className="whitespace-pre-wrap">{selectedCommission.background_request}</p>
</div>
)}
</div>
</div>
</div>
)}
</div>
</AuthenticatedLayout>
);
}
```
๋ค๋น๊ฒ์ด์
์ ํฌํธํด๋ฆฌ์ค ๋งํฌ๋ฅผ ์ถ๊ฐํ์ธ์. `resources/js/Layouts/AuthenticatedLayout.jsx`๋ฅผ ์ด๊ณ ๋ค๋น๊ฒ์ด์
๋ฉ๋ด์ ๋ค์์ ์ถ๊ฐํ์ธ์:
```jsx
<Link
href={route('commissions.portfolio')}
className="text-gray-700 hover:text-gray-900"
>
ํฌํธํด๋ฆฌ์ค
</Link>
```
๋ธ๋ผ์ฐ์ ์์ `/portfolio`์ ์ ์ํ์ฌ ์๋ฃ๋ ์ํ๋ค์ด ๊ทธ๋ฆฌ๋๋ก ํ์๋๊ณ , ์ธ๋ค์ผ ํด๋ฆญ ์ ๋ชจ๋ฌ๋ก ์์ธ ์ ๋ณด๊ฐ ํ์๋๋์ง ํ์ธํ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: ์๋ฃ๋ ์ปค๋ฏธ์ ์ํ๋ค์ด ๋ฐ์ํ ๊ทธ๋ฆฌ๋๋ก ํ์๋๋ฉฐ, ์ธ๋ค์ผ์ hover ์ ํ๋ ์ ๋๋ฉ์ด์ ์ด ์ ์ฉ๋ฉ๋๋ค. ์ธ๋ค์ผ ํด๋ฆญ ์ ์ ์ฒด ํ๋ฉด ๋ชจ๋ฌ์ด fade-in ์ ๋๋ฉ์ด์ ๊ณผ ํจ๊ป ๋ํ๋๊ณ , ์ด๋ฏธ์ง์ ์๋ขฐ ์ ๋ณด๊ฐ ํ์๋ฉ๋๋ค. ๋ชจ๋ฌ ์ธ๋ถ ํด๋ฆญ ๋๋ X ๋ฒํผ์ผ๋ก ๋ซ์ ์ ์์ต๋๋ค.
์ ์ถ๋ ์ํ (0)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!