메뉴 레이아웃 수정, 관리자 설정화면 추가, 제품코드 및 카테고리 추가, 안티그래비티 사용

This commit is contained in:
choibk 2026-01-16 14:31:56 +09:00
parent bc403ff61b
commit 04016ae870
20 changed files with 3087 additions and 158 deletions

View File

@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use Illuminate\Http\Request;
class CategoryController extends Controller
{
public function index()
{
$categories = Category::orderBy('name')->get();
return view('settings.categories', compact('categories'));
}
public function store(Request $request)
{
$request->validate([
'name' => ['required', 'string', 'max:50', 'unique:categories,name'],
], [
'name.unique' => '이미 존재하는 카테고리입니다.',
]);
Category::create(['name' => $request->name]);
return back()->with('success', '카테고리가 추가되었습니다.');
}
public function destroy(Category $category)
{
$category->delete();
return back()->with('success', '카테고리가 삭제되었습니다.');
}
}

View File

@ -32,13 +32,15 @@ class ProductController extends Controller
public function input()
{
$title = '상품추가';
return view('product.input', compact('title'));
$categories = \App\Models\Category::orderBy('name')->get();
return view('product.input', compact('title', 'categories'));
}
public function edit(Product $product)
{
$title = '상품수정';
return view('product.edit', compact('product', 'title'));
$categories = \App\Models\Category::orderBy('name')->get();
return view('product.edit', compact('product', 'title', 'categories'));
}
public function update(Request $request, Product $product)
@ -104,56 +106,58 @@ class ProductController extends Controller
public function store(Request $request, StockService $stock)
{
// 1) 검증
// 1) 입력 값 검증 (배열 형태)
$data = $request->validate([
'name' => ['required', 'string', 'max:100'],
'sku' => ['required', 'string', 'max:255', 'unique:products,sku'],
'quantity' => ['nullable', 'numeric', 'min:0'], // 초기 재고, 없어도 됨
'price' => ['required', 'numeric', 'min:0'],
'image' => ['nullable', 'image', 'max:2048'],
'products' => ['required', 'array', 'min:1'],
'products.*.category' => ['nullable', 'string', 'max:50'],
'products.*.name' => ['required', 'string', 'max:100'],
'products.*.sku' => ['required', 'string', 'max:255', 'distinct', 'unique:products,sku'], // distinct: 요청 내 중복 체크
'products.*.manufacturer'=>['nullable', 'string', 'max:100'],
'products.*.price' => ['required', 'numeric', 'min:0'],
'products.*.quantity' => ['nullable', 'numeric', 'min:0'],
'products.*.image' => ['nullable', 'image', 'max:2048'],
], [
'name.required' => '상품명은 필수 입력 항목입니다.',
'sku.required' => 'SKU는 필수 입력 항목입니다.',
'sku.unique' => '이미 등록된 SKU입니다.',
'quantity.numeric' => '수량은 숫자여야 합니다.',
'quantity.min' => '수량은 0 이상이어야 합니다.',
'price.required' => '가격은 필수 입력 항목입니다.',
'price.numeric' => '가격은 숫자여야 합니다.',
'price.min' => '가격은 0 이상이어야 합니다.',
'image.image' => '업로드된 파일은 이미지여야 합니다.',
'image.max' => '이미지 파일 크기는 2MB 이하만 가능합니다.',
'products.*.name.required' => '상품명은 필수입니다.',
'products.*.sku.required' => 'SKU는 필수입니다.',
'products.*.sku.unique' => '이미 등록된 SKU가 존재합니다.',
'products.*.sku.distinct' => '입력된 상품 중 중복된 SKU가 있습니다.',
'products.*.price.required' => '가격은 필수입니다.',
]);
// 2) 초기 수량은 따로 빼 둔다 (입출고 처리용)
$initialQty = isset($data['quantity']) ? (int) $data['quantity'] : 0;
$savedCount = 0;
// 3) 상품 생성용 배열 (동영상처럼 quantity는 0으로 고정)
$productData = [
'name' => $data['name'],
'sku' => $data['sku'],
'price' => $data['price'],
'quantity' => 0, // 상품 재고는 0으로 생성
];
foreach ($data['products'] as $key => $item) {
// 2) 상품 데이터 준비
$productData = [
'category' => $item['category'] ?? null,
'name' => $item['name'],
'sku' => $item['sku'],
'manufacturer' => $item['manufacturer'] ?? null,
'price' => $item['price'],
'quantity' => 0, // 초기 재고 0
];
// 이미지가 있으면 경로만 추가
if ($request->hasFile('image')) {
$productData['image'] = $request
->file('image')
->store('products', 'public');
// 3) 이미지 처리 (배열 인덱스 매칭)
if ($request->hasFile("products.{$key}.image")) {
$productData['image'] = $request
->file("products.{$key}.image")
->store('products', 'public');
}
// 4) 상품 생성
$product = Product::create($productData);
$savedCount++;
// 5) 초기 재고 입고 처리
$initialQty = isset($item['quantity']) ? (int) $item['quantity'] : 0;
if ($initialQty > 0) {
$stock->setInitialStock($product, $initialQty);
}
}
// 4) 상품 등록
$product = Product::create($productData);
// 5) 초기 재고가 있으면 세팅 + 입출고 로그 기록
if ($initialQty > 0) {
// StockService 안에서 product.quantity 업데이트 + stock_logs 기록
$stock->setInitialStock($product, $initialQty);
}
// 6) 리다이렉트
// 6) 결과 응답
return redirect()
->route('product') // 실제 목록 라우트명에 맞게 수정
->with('success', '상품이 등록되었습니다.');
->route('product')
->with('success', $savedCount . '개의 상품이 등록되었습니다.');
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
class UserController extends Controller
{
public function index()
{
$users = User::orderBy('name')->get();
return view('settings.users.index', compact('users'));
}
public function create()
{
return view('settings.users.create');
}
public function store(Request $request)
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'is_admin' => false,
]);
return redirect()->route('settings.users.index')->with('success', '사용자가 등록되었습니다.');
}
public function destroy(User $user)
{
if ($user->id === auth()->id()) {
return back()->with('error', '자기 자신은 삭제할 수 없습니다.');
}
$user->delete();
return back()->with('success', '사용자가 삭제되었습니다.');
}
public function promote(User $user)
{
if ($user->id === auth()->id()) {
return back()->with('error', '자기 자신의 권한은 변경할 수 없습니다.');
}
$user->update(['is_admin' => !$user->is_admin]);
$status = $user->is_admin ? '관리자로 지정되었습니다.' : '관리자 권한이 해제되었습니다.';
return back()->with('success', $status);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class IsAdmin
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
if (!auth()->check() || !auth()->user()->is_admin) {
abort(403, '관리자 권한이 필요합니다.');
}
return $next($request);
}
}

11
app/Models/Category.php Normal file
View File

@ -0,0 +1,11 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Category extends Model
{
protected $table = 'categories';
protected $guarded = [];
}

View File

@ -43,6 +43,7 @@ class User extends Authenticatable
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'is_admin' => 'boolean',
];
}
}

View File

@ -0,0 +1,23 @@
<?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::table('products', function (Blueprint $table) {
$table->string('category')->nullable()->after('id')->comment('상품분류');
$table->string('manufacturer')->nullable()->after('sku')->comment('제조사');
});
}
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropColumn(['category', 'manufacturer']);
});
}
};

View File

@ -0,0 +1,22 @@
<?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('categories', function (Blueprint $table) {
$table->id();
$table->string('name')->unique()->comment('카테고리명');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('categories');
}
};

View File

@ -0,0 +1,22 @@
<?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::table('users', function (Blueprint $table) {
$table->boolean('is_admin')->default(false)->after('password')->comment('관리자 여부');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('is_admin');
});
}
};

2421
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,7 @@
<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>
<p class="card-text display-5 fw-bold text-primary">{{ number_format($totalProducts) }}</p>
</div>
</div>
</div>
@ -20,7 +20,7 @@
<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>
<p class="card-text display-5 fw-bold text-success">{{ number_format($totalStock) }}</p>
</div>
</div>
</div>
@ -44,19 +44,24 @@
</tr>
</thead>
<tbody>
@forelse($recentHistory as $log)
<tr>
<td>상품명1</td>
<td><span class="badge bg-success">입고</span></td>
<td>00</td>
<td>YYYY-mm-dd HH:ii:ss</td>
<td>{{ $log->product->name ?? '삭제된 상품' }}</td>
<td>
@if($log->change_type === 'in')
<span class="badge bg-success">입고</span>
@else
<span class="badge bg-danger">출고</span>
@endif
</td>
<td>{{ number_format($log->change_amount) }}</td>
<td>{{ $log->created_at->format('Y-m-d H:i') }}</td>
</tr>
@empty
<tr>
<td>상품명2</td>
<td><span class="badge bg-danger">출고</span></td>
<td>00</td>
<td>YYYY-mm-dd HH:ii:ss</td>
<td colspan="4" class="text-center py-4 text-muted">최근 이력이 없습니다.</td>
</tr>
@endforelse
</tbody>
</table>
</div>

View File

@ -1,37 +1,94 @@
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="{{ route('dashboard') }}">재고관리</a>
{{-- Logo on the left --}}
<a class="navbar-brand fw-bold" href="{{ route('dashboard') }}">
📊 대시보드
</a>
<div class="collapse navbar-collapse">
<ul class="navbar-nav me-auto">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
{{-- Menu items on the right --}}
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="{{ route('product') }}">상품</a>
<a class="nav-link {{ request()->routeIs('product*') ? 'active' : '' }}" href="{{ route('product') }}">재고관리</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('product.input') }}">상품 추가</a>
<a class="nav-link {{ request()->routeIs('stock.*') ? 'active' : '' }}" href="{{ route('stock.list') }}">입출고 이력</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('stock.list') }}">입출고 이력</a>
<a class="nav-link {{ request()->routeIs('stats') ? 'active' : '' }}" href="{{ route('stats') }}">통계</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('stats') }}">통계</a>
@if(auth()->check() && auth()->user()->is_admin)
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {{ request()->routeIs('settings.*') ? 'active' : '' }}" href="javascript:void(0);" id="settingsDropdown" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
설정
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="settingsDropdown">
<li><a class="dropdown-item {{ request()->routeIs('settings.categories.*') ? 'active' : '' }}" href="{{ route('settings.categories.index') }}">카테고리</a></li>
<li><a class="dropdown-item {{ request()->routeIs('settings.users.*') ? 'active' : '' }}" href="{{ route('settings.users.index') }}">사용자 관리</a></li>
</ul>
</li>
@endif
{{-- 로그아웃 버튼 --}}
<li class="nav-item">
<form method="POST" action="{{ route('logout') }}">
<form method="POST" action="{{ route('logout') }}" class="d-inline">
@csrf
<button class="btn nav-link text-white" style="background:none; border:none;" type="submit">
로그아웃
<button class="btn btn-link logout-btn" type="submit" title="로그아웃">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-box-arrow-right" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10 12.5a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 0 1 0v-2A1.5 1.5 0 0 0 9.5 2h-8A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-2a.5.5 0 0 0-1 0z"/>
<path fill-rule="evenodd" d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 0 0-.708.708L14.293 7.5H5.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708z"/>
</svg>
</button>
</form>
</li>
</ul>
</div>
</div>
</nav>
</nav>
<style>
.navbar-dark .navbar-nav .nav-link {
color: rgba(255, 255, 255, 0.55);
transition: color 0.2s ease;
}
.navbar-dark .navbar-nav .nav-link:hover {
color: rgba(255, 255, 255, 0.85);
}
.navbar-dark .navbar-nav .nav-link.active {
color: #fff;
font-weight: 500;
border-bottom: 2px solid #0d6efd;
}
.dropdown-menu .dropdown-item.active {
background-color: #0d6efd;
color: #fff;
}
.navbar-brand {
font-size: 1.25rem;
}
.logout-btn {
color: rgba(255, 255, 255, 0.35) !important;
padding: 0.5rem;
text-decoration: none;
border: none;
background: none;
transition: color 0.2s ease;
}
.logout-btn:hover {
color: rgba(255, 255, 255, 0.65) !important;
}
</style>

View File

@ -14,5 +14,6 @@
@yield('main')
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@ -1,54 +1,128 @@
@extends('layout')
@section('main')
<div class="py-4">
<h2 class="mb-4 text-center">📝 상품 추가</h2>
<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;">
{{-- 에러 메시지 종합 출력 --}}
@if ($errors->any())
<div class="alert alert-danger">
<ul class="mb-0">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<form method="post" action="{{ route('product.store') }}" enctype="multipart/form-data">
@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="table-responsive">
<table class="table table-bordered align-middle" id="productTable">
<thead class="table-light text-center">
<tr>
<th style="width: 150px;">분류 (Category)</th>
<th style="width: 150px;">상품명 <span class="text-danger">*</span></th>
<th style="width: 120px;">상품코드 <span class="text-danger">*</span></th>
<th style="width: 120px;">제조사</th>
<th style="width: 100px;">단가 <span class="text-danger">*</span></th>
<th style="width: 80px;">수량</th>
<th>이미지</th>
<th style="width: 60px;">삭제</th>
</tr>
</thead>
<tbody>
{{-- 초기 1 --}}
<tr class="product-row">
<td>
<select name="products[0][category]" class="form-select">
<option value="">(선택)</option>
@foreach($categories as $category)
<option value="{{ $category->name }}">{{ $category->name }}</option>
@endforeach
</select>
</td>
<td><input type="text" name="products[0][name]" class="form-control" required placeholder="상품명"></td>
<td><input type="text" name="products[0][sku]" class="form-control" required placeholder="고유코드"></td>
<td><input type="text" name="products[0][manufacturer]" class="form-control" placeholder="제조사"></td>
<td><input type="number" name="products[0][price]" class="form-control" required min="0" placeholder="0"></td>
<td><input type="number" name="products[0][quantity]" class="form-control" min="0" placeholder="0"></td>
<td><input type="file" name="products[0][image]" class="form-control"></td>
<td class="text-center">
<button type="button" class="btn btn-sm btn-danger remove-row" disabled>-</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="d-grid gap-2">
<button class="btn btn-success">저장</button>
<a href="products.php" class="btn btn-outline-secondary">취소</a>
<div class="d-flex justify-content-between mt-3">
<button type="button" class="btn btn-secondary" id="addRowBtn">+ 추가</button>
<div>
<a href="{{ route('product') }}" class="btn btn-outline-secondary me-2">취소</a>
<button class="btn btn-primary">일괄 저장</button>
</div>
</div>
</form>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
let rowIndex = 1;
const tableBody = document.querySelector('#productTable tbody');
const addRowBtn = document.getElementById('addRowBtn');
// 행 추가
addRowBtn.addEventListener('click', function() {
const template = `
<tr class="product-row">
<td>
<select name="products[${rowIndex}][category]" class="form-select">
<option value="">(선택)</option>
@foreach($categories as $category)
<option value="{{ $category->name }}">{{ $category->name }}</option>
@endforeach
</select>
</td>
<td><input type="text" name="products[${rowIndex}][name]" class="form-control" required placeholder="상품명"></td>
<td><input type="text" name="products[${rowIndex}][sku]" class="form-control" required placeholder="고유코드"></td>
<td><input type="text" name="products[${rowIndex}][manufacturer]" class="form-control" placeholder="제조사"></td>
<td><input type="number" name="products[${rowIndex}][price]" class="form-control" required min="0" placeholder="0"></td>
<td><input type="number" name="products[${rowIndex}][quantity]" class="form-control" min="0" placeholder="0"></td>
<td><input type="file" name="products[${rowIndex}][image]" class="form-control"></td>
<td class="text-center">
<button type="button" class="btn btn-sm btn-danger remove-row">-</button>
</td>
</tr>
`;
tableBody.insertAdjacentHTML('beforeend', template);
rowIndex++;
updateRemoveButtons();
});
// 행 삭제
tableBody.addEventListener('click', function(e) {
if (e.target.classList.contains('remove-row')) {
const row = e.target.closest('tr');
// 최소 1개 행 유지
if (document.querySelectorAll('.product-row').length > 1) {
row.remove();
updateRemoveButtons();
}
}
});
function updateRemoveButtons() {
const rows = document.querySelectorAll('.product-row');
const buttons = document.querySelectorAll('.remove-row');
if (rows.length === 1) {
buttons[0].disabled = true;
} else {
buttons.forEach(btn => btn.disabled = false);
}
}
});
</script>
@endsection

View File

@ -41,10 +41,12 @@
<thead class="table-light">
<tr>
<th>ID</th>
<th>분류</th>
<th>상품명</th>
<th>SKU</th>
<th>상품코드</th>
<th>제조사</th>
<th>수량</th>
<th></th>
<th></th>
<th>관리</th>
</tr>
</thead>
@ -52,8 +54,10 @@
@foreach ($products as $product)
<tr>
<td>{{ $product->id }}</td>
<td>{{ $product->category }}</td>
<td>{{ $product->name }}</td>
<td>{{ $product->sku }}</td>
<td>{{ $product->manufacturer }}</td>
<td>{{ number_format($product->quantity) }}</td>
<td>{{ number_format($product->price) }} </td>
<td>

View File

@ -0,0 +1,47 @@
@extends('layout')
@section('main')
<div class="py-4 container" style="max-width: 600px;">
<h2 class="mb-4 text-center">⚙️ 카테고리 관리</h2>
@if (\Session::has('success'))
<div class="alert alert-success">{{ \Session::get('success') }}</div>
@endif
<div class="card shadow-sm mb-4">
<div class="card-body">
<h5 class="card-title mb-3">카테고리 추가</h5>
<form action="{{ route('settings.categories.store') }}" method="POST" class="d-flex gap-2">
@csrf
<div class="flex-grow-1">
<input type="text" name="name" class="form-control" placeholder="새 카테고리 이름" required>
@error('name')
<div class="text-danger small mt-1">{{ $message }}</div>
@enderror
</div>
<button class="btn btn-primary" style="white-space: nowrap;">추가</button>
</form>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header bg-white">
<h5 class="mb-0">등록된 카테고리</h5>
</div>
<ul class="list-group list-group-flush">
@forelse($categories as $category)
<li class="list-group-item d-flex justify-content-between align-items-center">
{{ $category->name }}
<form action="{{ route('settings.categories.destroy', $category) }}" method="POST" onsubmit="return confirm('정말 삭제하시겠습니까?');">
@csrf
@method('DELETE')
<button class="btn btn-sm btn-outline-danger">삭제</button>
</form>
</li>
@empty
<li class="list-group-item text-center text-muted">등록된 카테고리가 없습니다.</li>
@endforelse
</ul>
</div>
</div>
@endsection

View File

@ -0,0 +1,46 @@
@extends('layout')
@section('main')
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header bg-white py-3">
<h4 class="mb-0">사용자 추가</h4>
</div>
<div class="card-body">
<form action="{{ route('settings.users.store') }}" method="POST">
@csrf
<div class="mb-3">
<label class="form-label">이름</label>
<input type="text" name="name" class="form-control" required value="{{ old('name') }}">
@error('name') <div class="text-danger small">{{ $message }}</div> @enderror
</div>
<div class="mb-3">
<label class="form-label">이메일</label>
<input type="email" name="email" class="form-control" required value="{{ old('email') }}">
@error('email') <div class="text-danger small">{{ $message }}</div> @enderror
</div>
<div class="mb-3">
<label class="form-label">비밀번호</label>
<input type="password" name="password" class="form-control" required>
@error('password') <div class="text-danger small">{{ $message }}</div> @enderror
</div>
<div class="mb-4">
<label class="form-label">비밀번호 확인</label>
<input type="password" name="password_confirmation" class="form-control" required>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">등록하기</button>
<a href="{{ route('settings.users.index') }}" class="btn btn-light">취소</a>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection

View File

@ -0,0 +1,70 @@
@extends('layout')
@section('main')
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>사용자 관리</h2>
<a href="{{ route('settings.users.create') }}" class="btn btn-primary">
+ 사용자 추가
</a>
</div>
@if(session('success'))
<div class="alert alert-success">{{ session('success') }}</div>
@endif
@if(session('error'))
<div class="alert alert-danger">{{ session('error') }}</div>
@endif
<div class="card shadow-sm">
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>이름</th>
<th>이메일</th>
<th>권한</th>
<th>가입일</th>
<th class="text-end">관리</th>
</tr>
</thead>
<tbody>
@foreach($users as $user)
<tr class="align-middle">
<td>{{ $user->name }}</td>
<td>{{ $user->email }}</td>
<td>
@if($user->is_admin)
<span class="badge bg-danger">관리자</span>
@else
<span class="badge bg-secondary">일반 회원</span>
@endif
</td>
<td>{{ $user->created_at->format('Y-m-d') }}</td>
<td class="text-end">
@if($user->id !== auth()->id())
<form action="{{ route('settings.users.promote', $user) }}" method="POST" class="d-inline">
@csrf
@method('PATCH')
@if($user->is_admin)
<button class="btn btn-sm btn-outline-warning">관리자 해제</button>
@else
<button class="btn btn-sm btn-outline-success">관리자 지정</button>
@endif
</form>
<form action="{{ route('settings.users.destroy', $user) }}" method="POST" class="d-inline" onsubmit="return confirm('정말 삭제하시겠습니까?');">
@csrf
@method('DELETE')
<button class="btn btn-sm btn-outline-danger">삭제</button>
</form>
@else
<span class="text-muted small me-2">(본인)</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endsection

View File

@ -10,57 +10,53 @@ use Illuminate\Support\Facades\Route;
// ① 기본 URL: 로그인 된 사용자만 접근 가능, 아니면 /login 으로 리다이렉트
Route::get('/', function () {
return view('dashboard'); // resources/views/dashboard.blade.php
})->middleware(Authenticate::class)
->name('dashboard');
$totalProducts = \App\Models\Product::count();
$totalStock = \App\Models\Product::sum('quantity');
$recentHistory = \App\Models\StockLog::with('product')->latest()->take(5)->get();
// ② 로그인 화면 (누구나 접근 가능)
Route::get('/login', [LoginController::class, 'showLoginForm'])
->name('login');
return view('dashboard', compact('totalProducts', 'totalStock', 'recentHistory'));
})->middleware(Authenticate::class)->name('dashboard');
// ③ 로그인 처리 (POST)
Route::post('/login', [LoginController::class, 'login'])
->name('login.process');
// 로그인 관련 라우트 (비로그인 사용자 접근 가능)
Route::get('/login', [LoginController::class, 'showLoginForm'])->name('login');
Route::post('/login', [LoginController::class, 'login'])->name('login.process');
Route::post('/logout', [LoginController::class, 'logout'])->name('logout');
// 로그아웃
Route::post('/logout', [LoginController::class, 'logout'])
->name('logout');
// 인증된 사용자만 접근 가능한 그룹
Route::middleware([Authenticate::class])->group(function () {
// 대시보드 (위에서 정의했지만 명시적으로 그룹에 포함 가능, 또는 별도 유지)
// Route::get('/dashboard', ...);
// 상품추가
Route::get('/product/input', [ProductController::class, 'input'])
->name('product.input');
// 상품 관련
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::get('/product/{product}/edit', [ProductController::class, 'edit'])->name('product.edit');
Route::put('/product/{product}', [ProductController::class, 'update'])->name('product.update');
Route::get('/product/export', ProductExportController::class)->name('products.export');
Route::post('/product/input', [ProductController::class, 'store'])
->name('product.store');
// 입출고 관련
Route::get('/stock/input/{id}', [StockLogController::class, 'input'])->name('stock.input');
Route::post('/stock/input/{id}', [StockLogController::class, 'store'])->name('stock.store');
Route::get('/stock', [StockLogController::class, 'index'])->name('stock.list');
// 상품목록
Route::get('/product', [ProductController::class, 'index'])
->name('product');
// 통계
Route::get('/stats', [statsController::class, 'index'])->name('stats');
// 상품삭제
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('/stock/input/{id}', [StockLogController::class, 'input'])
->name('stock.input');
Route::post('/stock/input/{id}', [StockLogController::class, 'store'])
->name('stock.store');
// 입출고 이력
Route::get('/stock', [StockLogController::class, 'index'])
->name('stock.list');
// 통계
Route::get('/stats', [statsController::class, 'index'])
->name('stats');
// 다운로드
Route::get('/product/export', ProductExportController::class)
->name('products.export');
// 관리자 전용 설정
Route::middleware([\App\Http\Middleware\IsAdmin::class])->group(function () {
// 카테고리 관리
Route::get('/settings/categories', [App\Http\Controllers\CategoryController::class, 'index'])->name('settings.categories.index');
Route::post('/settings/categories', [App\Http\Controllers\CategoryController::class, 'store'])->name('settings.categories.store');
Route::delete('/settings/categories/{category}', [App\Http\Controllers\CategoryController::class, 'destroy'])->name('settings.categories.destroy');
// 사용자 관리 (추후 구현 예정)
Route::get('/settings/users', [App\Http\Controllers\UserController::class, 'index'])->name('settings.users.index');
Route::get('/settings/users/create', [App\Http\Controllers\UserController::class, 'create'])->name('settings.users.create');
Route::post('/settings/users', [App\Http\Controllers\UserController::class, 'store'])->name('settings.users.store');
Route::delete('/settings/users/{user}', [App\Http\Controllers\UserController::class, 'destroy'])->name('settings.users.destroy');
Route::patch('/settings/users/{user}/promote', [App\Http\Controllers\UserController::class, 'promote'])->name('settings.users.promote');
});
});

View File

@ -10,4 +10,11 @@ export default defineConfig({
}),
tailwindcss(),
],
server: {
host: '0.0.0.0',
port: 5173,
hmr: {
host: process.env.VITE_HMR_HOST || 'localhost',
},
},
});