13. 상품수정까지

This commit is contained in:
choibk 2025-12-06 18:58:09 +09:00
parent 104cfc2b90
commit 83c569b231
17 changed files with 648 additions and 17 deletions

View File

@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
class LoginController extends Controller
{
public function showLoginForm()
{
if (Auth::check()) {
return redirect()->route('dashboard');
}
return view('login');
}
public function login(Request $request)
{
try {
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
} catch (ValidationException $e) {
return redirect()
->back()
->withErrors($e->errors())
->withInput()
->with('error', '입력 값 검증 실패');
}
if (Auth::attempt($credentials, false)) {
$request->session()->regenerate();
return redirect()
->intended('/')
->with('success', '로그인에 성공하였습니다.');
}
return redirect()
->back()
->withInput()
->with('error', '입력한 자격증명이 올바르지 않습니다.');
}
public function logout(Request $request)
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
}

View File

@ -0,0 +1,139 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Product;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
class ProductController extends Controller
{
public function index()
{
$title = '상품목록';
$products = Product::searchkeyword();
return view('product.list', compact('title', 'products'));
}
public function input()
{
$title = '상품추가';
return view('product.input', compact('title'));
}
public function edit(Product $product)
{
$title = '상품수정';
return view('product.edit', compact('product', 'title'));
}
public function update(Request $request, Product $product)
{
// 1) 입력 값 검증
$data = $request->validate([
'name' => ['required', 'string'],
'sku' => [
'required',
'string',
Rule::unique('products', 'sku')->ignore($product->id), // 현재 상품은 예외
],
'price' => ['required', 'numeric', 'min:0'],
'image' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
]);
// 2) 텍스트 데이터 업데이트
$product->name = $data['name'];
$product->sku = $data['sku'];
$product->price = $data['price'];
// 3) 이미지 처리
if ($request->hasFile('image')) {
// 기존 이미지가 있으면 삭제
if ($product->image && Storage::disk('public')->exists($product->image)) {
Storage::disk('public')->delete($product->image);
// Storage 파사드를 이용하면, 저장소 드라이버가 바뀌어도 코드 수정 최소화
}
// 새 이미지 저장
$path = $request->file('image')->store('uploads', 'public');
$product->image = $path;
}
// 4) 저장
$product->save();
// 5) 결과 응답
return redirect()
->route('product')
->with('success', '상품정보가 수정되었습니다.');
}
public function destroy($id)
{
// 1. 모델을 먼저 가져온다
$product = Product::findOrFail($id);
// 2. 이미지가 있는 경우만 삭제
if (!empty($product->image)) {
Storage::disk('public')->delete($product->image);
}
// 3. DB 레코드 삭제
$product->delete();
// 4. 목록으로 이동
return redirect()
->route('product')
->with('success', '상품이 삭제되었습니다.');
}
public function store(Request $request)
{
// 1) 검증 + 중복 검사 + 사용자 정의 메시지
$data = $request->validate([
'name' => ['required', 'string', 'max:100'],
'sku' => ['required', 'string', 'max:255', 'unique:products,sku'],
'quantity' => ['required', 'numeric', 'min:0'],
'price' => ['required', 'numeric', 'min:0'],
'image' => ['nullable', 'image', 'max:2048'],
], [
// name
'name.required' => '상품명은 필수 입력 항목입니다.',
'name.unique' => '이미 등록된 상품명입니다.',
// sku
'sku.required' => 'SKU는 필수 입력 항목입니다.',
'sku.string' => 'SKU는 문자열이어야 합니다.',
'sku.unique' => '이미 등록된 SKU입니다.',
// quantity
'quantity.required' => '수량은 필수 입력 항목입니다.',
'quantity.numeric' => '수량은 숫자여야 합니다.',
'quantity.min' => '수량은 0 이상이어야 합니다.',
// price
'price.required' => '가격은 필수 입력 항목입니다.',
'price.numeric' => '가격은 숫자여야 합니다.',
'price.min' => '가격은 0 이상이어야 합니다.',
// image
'image.image' => '업로드된 파일은 이미지여야 합니다.',
'image.max' => '이미지 파일 크기는 2MB 이하만 가능합니다.',
]);
// 2) 이미지 업로드 처리
if ($request->hasFile('image')) {
$data['image'] = $request->file('image')->store('products', 'public');
// 저장 경로 예: storage/app/public/products/파일명.jpg
}
// 3) DB 저장
Product::create($data);
// 4) 등록 후 리다이렉트
return redirect()
->route('product.input')
->with('success', '상품이 등록되었습니다.');
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Auth\Middleware\Authenticate as Middleware;
class Authenticate extends Middleware
{
/**
* 인증 사용자가 접근했을 어디로 보낼지 지정
*/
protected function redirectTo($request): ?string
{
if (! $request->expectsJson()) {
return route('login'); // routes/web.php 에서 name('login') 사용 중
}
return null;
}
}

16
app/Models/Product.php Normal file
View File

@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
protected $table = 'products';
protected $guarded = [];
static public function searchkeyword()
{
$return = self::select('id', 'name', 'sku', 'price', 'quantity', 'created_at');
return $return->paginate(1);
}
}

View File

@ -3,6 +3,7 @@
namespace App\Providers; namespace App\Providers;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Pagination\Paginator;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@ -19,6 +20,6 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
// Paginator::useBootstrap();
} }
} }

View File

@ -11,7 +11,7 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// // 커스텀 alias 전부 제거 비워둠
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
// //

View File

@ -65,7 +65,7 @@ return [
| |
*/ */
'timezone' => 'UTC', 'timezone' => 'Asia/Seoul',
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->id(); // 기본 PK, auto_increment
$table->string('name', 100)->comment('상품명');
$table->string('sku')
->unique()
->comment('SKU');
$table->integer('quantity')
->default(0)
->comment('수량');
$table->integer('price')
->comment('가격');
$table->string('image')
->nullable()
->comment('상품이미지');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('products');
}
};

View File

@ -5,6 +5,7 @@ namespace Database\Seeders;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class DatabaseSeeder extends Seeder class DatabaseSeeder extends Seeder
{ {
@ -18,8 +19,9 @@ class DatabaseSeeder extends Seeder
// User::factory(10)->create(); // User::factory(10)->create();
User::factory()->create([ User::factory()->create([
'name' => 'Test User', 'name' => 'sokuree',
'email' => 'test@example.com', 'email' => 'sokuree@sokuree.com',
'password' => Hash::make('password')
]); ]);
} }
} }

View File

@ -0,0 +1,66 @@
@extends('layout')
@section('main')
{{-- 페이지 제목 --}}
<div class="py-4">
<h2 class="mb-4 text-center">📦 재고관리 대시보드</h2>
{{-- 요약 카드 --}}
<div class="row mb-4">
<div class="col-md-6 mb-3">
<div class="card shadow-sm border-start border-4 border-primary">
<div class="card-body">
<h5 class="card-title"> 상품 </h5>
<p class="card-text display-5 fw-bold text-primary">00</p>
</div>
</div>
</div>
<div class="col-md-6 mb-3">
<div class="card shadow-sm border-start border-4 border-success">
<div class="card-body">
<h5 class="card-title">전체 재고 수량</h5>
<p class="card-text display-5 fw-bold text-success">000</p>
</div>
</div>
</div>
</div>
{{-- 최근 입출고 이력 --}}
<div class="card shadow-sm">
<div class="card-header bg-white border-bottom d-flex justify-content-between align-items-center">
<h5 class="mb-0">🕓 최근 입출고 이력</h5>
<a href="#" class="btn btn-sm btn-outline-primary">전체 보기</a>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>상품명</th>
<th>유형</th>
<th>수량</th>
<th>날짜</th>
</tr>
</thead>
<tbody>
<tr>
<td>상품명1</td>
<td><span class="badge bg-success">입고</span></td>
<td>00</td>
<td>YYYY-mm-dd HH:ii:ss</td>
</tr>
<tr>
<td>상품명2</td>
<td><span class="badge bg-danger">출고</span></td>
<td>00</td>
<td>YYYY-mm-dd HH:ii:ss</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
@endsection

View File

@ -0,0 +1,37 @@
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="{{ route('dashboard') }}">재고관리</a>
<div class="collapse navbar-collapse">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="{{ route('product') }}">상품</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('product.input') }}">상품 추가</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">입출고 이력</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">통계</a>
</li>
{{-- 로그아웃 버튼 --}}
<li class="nav-item">
<form method="POST" action="{{ route('logout') }}">
@csrf
<button class="btn nav-link text-white" style="background:none; border:none;" type="submit">
로그아웃
</button>
</form>
</li>
</ul>
</div>
</div>
</nav>

View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>{{ $title ?? '대시보드' }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
@include('inc.topbar')
<div class="container mt-4">
@yield('main')
</div>
</body>
</html>

View File

@ -10,17 +10,18 @@
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-md-4"> <div class="col-md-4">
<h4 class="mb-3">관리자 로그인</h4> <h4 class="mb-3">관리자 로그인</h4>
@if (session('error'))
<div class="alert alert-danger">메시지</div> <div class="alert alert-danger">{{session('error')}}</div>
@endif
<form> <form method="POST" action="{{ route('login.process') }}">
@csrf()
<div class="mb-3"> <div class="mb-3">
<label class="form-label">아이디</label> <label class="form-label">이메일</label>
<input type="text" name="field1" class="form-control" required> <input type="text" name="email" value="{{ old('email') }}" class="form-control" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">비밀번호</label> <label class="form-label">비밀번호</label>
<input type="password" name="file2" class="form-control" required> <input type="password" name="password" class="form-control" required>
</div> </div>
<button class="btn btn-primary w-100">로그인</button> <button class="btn btn-primary w-100">로그인</button>
</form> </form>

View File

@ -0,0 +1,49 @@
@extends('layout')
@section('main')
<div class="py-4">
<h2 class="mb-4 text-center">📝 상품 수정</h2>
<form method="post" action="{{ route('product.update', $product) }}" enctype="multipart/form-data" class="mx-auto"
style="max-width: 600px;">
@csrf
@method('PUT')
<div class="mb-3">
<label class="form-label">상품명</label>
<input type="text" name="name" class="form-control" value="{{ old('name', $product->name) }}" required>
@error('name')
<div class="alert alert-danger text-center">{{ $message }}</div>
@enderror
</div>
<div class="mb-3">
<label class="form-label">SKU</label>
<input type="text" name="sku" class="form-control" value="{{ old('sku', $product->sku) }}" required>
@error('sku')
<div class="alert alert-danger text-center">{{ $message }}</div>
@enderror
</div>
<div class="mb-3">
<label class="form-label">가격 ()</label>
<input type="number" step="0.01" name="price" class="form-control" value="{{ old('price', $product->price) }}" required>
@error('price')
<div class="alert alert-danger text-center">{{ $message }}</div>
@enderror
</div>
<div class="mb-3">
<label class="form-label">상품 이미지 (선택사항)</label>
<input type="file" name="image" class="form-control">
{{-- 기존 이미지 미리보기 --}}
@if($product->image)
<div class="mt-2">
<img src="{{ asset('storage/'. $product->image) }}" style="max-width: 200px;">
</div>
@endif
</div>
<div class="d-grid gap-2">
<button class="btn btn-success">저장</button>
<a href="products.php" class="btn btn-outline-secondary">취소</a>
</div>
</form>
</div>
@endsection

View File

@ -0,0 +1,54 @@
@extends('layout')
@section('main')
<div class="py-4">
<h2 class="mb-4 text-center">📝 상품 추가</h2>
<form method="post" action="{{ route('product.store') }}" enctype="multipart/form-data" class="mx-auto" style="max-width: 600px;">
@csrf
<div class="mb-3">
<label class="form-label">상품명</label>
<input type="text" name="name" class="form-control" value="{{ old('name') }}" required>
@error('name')
<div class="alert alert-danger text-center">{{ $message }}</div>
@enderror
</div>
<div class="mb-3">
<label class="form-label">SKU</label>
<input type="text" name="sku" class="form-control" value="{{ old('sku') }}" required>
@error('sku')
<div class="alert alert-danger text-center">{{ $message }}</div>
@enderror
</div>
<div class="mb-3">
<label class="form-label">수량</label>
<input type="number" name="quantity" class="form-control" value="" required>
@error('quantity')
<div class="alert alert-danger text-center">{{ $message }}</div>
@enderror
</div>
<div class="mb-3">
<label class="form-label">가격 ()</label>
<input type="number" step="0.01" name="price" class="form-control" value="" required>
@error('price')
<div class="alert alert-danger text-center">{{ $message }}</div>
@enderror
</div>
<div class="mb-3">
<label class="form-label">상품 이미지</label>
<input type="file" name="image" class="form-control">
<div class="mt-2">
<img src="이미지" alt="상품 이미지" style="max-width: 150px;">
</div>
</div>
<div class="d-grid gap-2">
<button class="btn btn-success">저장</button>
<a href="products.php" class="btn btn-outline-secondary">취소</a>
</div>
</form>
</div>
@endsection

View File

@ -0,0 +1,95 @@
@extends('layout')
@section('main')
<div class="py-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0">📋 상품 목록</h2>
<a href="product_form.php" class="btn btn-primary">+ 상품 추가</a>
</div>
@if(session('success'))
<div class="alert alert-success">{{ session('success') }}</div>
@endif
<form class="row g-3 mb-3" method="get">
<div class="col-md-4">
<input type="text" name="search" class="form-control" placeholder="상품명 또는 SKU 검색" value="">
</div>
<div class="col-md-3">
<select name="sort" class="form-select">
<option value="name">상품명</option>
<option value="quantity">수량</option>
</select>
</div>
<div class="col-md-3">
<select name="order" class="form-select">
<option value="asc">오름차순</option>
<option value="desc">내림차순</option>
</select>
</div>
<div class="col-md-2">
<button class="btn btn-outline-secondary w-100">검색</button>
</div>
</form>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>ID</th>
<th>상품명</th>
<th>SKU</th>
<th>수량</th>
<th>가격</th>
<th>관리</th>
</tr>
</thead>
<tbody>
@foreach ($products as $product)
<tr>
<td>{{ $product->id }}</td>
<td>{{ $product->name }}</td>
<td>{{ $product->sku }}</td>
<td>{{ number_format($product->quantity) }} </td>
<td>{{ number_format($product->price) }} </td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="{{ route('product.edit', $product) }}" class="btn btn-outline-primary">수정</a>
<a href="#" class="btn btn-outline-danger btn_del" data-id="{{ $product->id }}">삭제</a>
<a href="" class="btn btn-outline-success">입고</a>
<a href="" class="btn btn-outline-warning">출고</a>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
{{ $products->links() }}
</div>
{{-- 공용 삭제 --}}
<form id="deleteForm" method="POST" style="display: none">
@csrf
@method('DELETE')
</form>
<script>
const btn_dels = document.querySelectorAll('.btn_del');
btn_dels.forEach((e1)=> {
e1.addEventListener("click", function(e) {
e.preventDefault();
// alert(this.dataset.id);
if(!confirm('정말 삭제하시겠습니까?')) {
return;
}
const form= document.getElementById('deleteForm');
form.action = '/product/' + this.dataset.id;
form.submit();
})
})
</script>
@endsection

View File

@ -1,11 +1,47 @@
<?php <?php
use App\Http\Controllers\LoginController;
use App\Http\Controllers\ProductController;
use App\Http\Middleware\Authenticate;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
// ① 기본 URL: 로그인 된 사용자만 접근 가능, 아니면 /login 으로 리다이렉트
Route::get('/', function () { Route::get('/', function () {
return view('welcome'); return view('dashboard'); // resources/views/dashboard.blade.php
}); })->middleware(Authenticate::class)
->name('dashboard');
// ② 로그인 화면 (누구나 접근 가능)
Route::get('/login', [LoginController::class, 'showLoginForm'])
->name('login');
// ③ 로그인 처리 (POST)
Route::post('/login', [LoginController::class, 'login'])
->name('login.process');
// 로그아웃
Route::post('/logout', [LoginController::class, 'logout'])
->name('logout');
// 상품추가
Route::get('/product/input', [ProductController::class, 'input'])
->name('product.input');
Route::post('/product/input', [ProductController::class, 'store'])
->name('product.store');
// 상품목록
Route::get('/product', [ProductController::class, 'index'])
->name('product');
// 상품삭제
Route::delete('/product/{id}', [ProductController::class, 'destroy'])
->name('product.delete');
// 상품수정 Route Model Binding
Route::get('/product/{product}/edit', [ProductController::class, 'edit'])
->name('product.edit');
Route::put('/product/{product}', [ProductController::class, 'update'])
->name('product.update');
Route::get('/login', function () {
return view('login');
});