2025-11-26 20:40:54 +09:00
|
|
|
# MNG 테이블 레이아웃 표준
|
|
|
|
|
|
|
|
|
|
> **기준 페이지**: `/permissions` (권한 관리)
|
|
|
|
|
> **작성일**: 2025-11-25
|
|
|
|
|
> **목적**: mng 프로젝트의 모든 테이블 페이지에서 일관된 레이아웃과 UX를 제공
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 📋 목차
|
|
|
|
|
|
|
|
|
|
1. [페이지 구조](#1-페이지-구조)
|
|
|
|
|
2. [페이지 헤더](#2-페이지-헤더)
|
|
|
|
|
3. [필터 영역](#3-필터-영역)
|
|
|
|
|
4. [테이블 구조](#4-테이블-구조)
|
|
|
|
|
5. [페이지네이션](#5-페이지네이션)
|
|
|
|
|
6. [기술 스택](#6-기술-스택)
|
|
|
|
|
7. [체크리스트](#7-체크리스트)
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 1. 페이지 구조
|
|
|
|
|
|
|
|
|
|
### 1.1 전체 레이아웃 순서
|
|
|
|
|
|
|
|
|
|
```blade
|
|
|
|
|
@extends('layouts.app')
|
|
|
|
|
|
|
|
|
|
@section('content')
|
|
|
|
|
<!-- ① 페이지 헤더 -->
|
|
|
|
|
<div class="flex justify-between items-center mb-6">
|
|
|
|
|
<!-- 제목 + 액션 버튼 -->
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- ② 필터 영역 (선택사항) -->
|
|
|
|
|
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
|
|
|
|
<!-- 검색, 필터 폼 -->
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- ③ 테이블 영역 (HTMX) -->
|
|
|
|
|
<div id="{resource}-table"
|
|
|
|
|
hx-get="/api/admin/{resource}"
|
|
|
|
|
hx-trigger="load, filterSubmit from:body"
|
|
|
|
|
hx-include="#filterForm"
|
|
|
|
|
class="bg-white rounded-lg shadow-sm overflow-hidden">
|
|
|
|
|
<!-- 로딩 스피너 -->
|
|
|
|
|
<!-- 테이블 partial 로드됨 -->
|
|
|
|
|
</div>
|
|
|
|
|
@endsection
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 1.2 파일 구조
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
resources/views/{resource}/
|
|
|
|
|
├── index.blade.php # 메인 페이지 (레이아웃만)
|
|
|
|
|
├── create.blade.php # 생성 폼
|
|
|
|
|
├── edit.blade.php # 수정 폼
|
|
|
|
|
└── partials/
|
|
|
|
|
└── table.blade.php # 테이블 + 페이지네이션
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 2. 페이지 헤더
|
|
|
|
|
|
|
|
|
|
### 2.1 기본 구조
|
|
|
|
|
|
|
|
|
|
```blade
|
|
|
|
|
<div class="flex justify-between items-center mb-6">
|
|
|
|
|
<h1 class="text-2xl font-bold text-gray-800">{페이지 제목}</h1>
|
|
|
|
|
<a href="{{ route('{resource}.create') }}"
|
|
|
|
|
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition">
|
|
|
|
|
+ {액션 버튼 레이블}
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 2.2 스타일 규칙
|
|
|
|
|
|
|
|
|
|
- **제목**: `text-2xl font-bold text-gray-800`
|
|
|
|
|
- **액션 버튼**: `bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition`
|
|
|
|
|
- **간격**: `mb-6` (하단 여백)
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 3. 필터 영역
|
|
|
|
|
|
|
|
|
|
### 3.1 기본 구조
|
|
|
|
|
|
|
|
|
|
```blade
|
|
|
|
|
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
|
|
|
|
<form id="filterForm" class="flex gap-4">
|
|
|
|
|
<!-- 검색 입력 -->
|
|
|
|
|
<div class="flex-1">
|
|
|
|
|
<input type="text"
|
|
|
|
|
name="search"
|
|
|
|
|
placeholder="{항목명}으로 검색..."
|
|
|
|
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 드롭다운 필터 (선택사항) -->
|
|
|
|
|
<div class="w-48">
|
|
|
|
|
<select name="{filter_name}"
|
|
|
|
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
|
|
|
<option value="">전체 {필터명}</option>
|
|
|
|
|
<!-- 옵션들 -->
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 검색 버튼 -->
|
|
|
|
|
<button type="submit"
|
|
|
|
|
class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition">
|
|
|
|
|
검색
|
|
|
|
|
</button>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 3.2 스타일 규칙
|
|
|
|
|
|
|
|
|
|
- **컨테이너**: `bg-white rounded-lg shadow-sm p-4 mb-6`
|
|
|
|
|
- **폼**: `flex gap-4` (가로 배치, 간격 4)
|
|
|
|
|
- **검색 입력**: `flex-1` (가변 폭)
|
|
|
|
|
- **드롭다운**: `w-48` (고정 폭 192px)
|
|
|
|
|
- **버튼**: `bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition`
|
|
|
|
|
|
|
|
|
|
### 3.3 JavaScript 이벤트
|
|
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
|
document.getElementById('filterForm').addEventListener('submit', function(e) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
htmx.trigger('#{resource}-table', 'filterSubmit');
|
|
|
|
|
});
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 4. 테이블 구조
|
|
|
|
|
|
|
|
|
|
### 4.1 HTMX 컨테이너
|
|
|
|
|
|
|
|
|
|
```blade
|
|
|
|
|
<div id="{resource}-table"
|
|
|
|
|
hx-get="/api/admin/{resource}"
|
|
|
|
|
hx-trigger="load, filterSubmit from:body"
|
|
|
|
|
hx-include="#filterForm"
|
|
|
|
|
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
|
|
|
|
|
class="bg-white rounded-lg shadow-sm overflow-hidden">
|
|
|
|
|
<!-- 로딩 스피너 -->
|
|
|
|
|
<div class="flex justify-center items-center p-12">
|
|
|
|
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 4.2 테이블 Partial (`partials/table.blade.php`)
|
|
|
|
|
|
|
|
|
|
```blade
|
|
|
|
|
<div class="overflow-x-auto">
|
|
|
|
|
<table class="min-w-full divide-y divide-gray-200">
|
|
|
|
|
<thead class="bg-gray-50">
|
|
|
|
|
<tr>
|
|
|
|
|
<th class="px-4 py-2 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
|
|
|
|
|
{컬럼명}
|
|
|
|
|
</th>
|
|
|
|
|
<!-- 추가 컬럼들 -->
|
|
|
|
|
<th class="px-4 py-2 text-right text-sm font-semibold text-gray-700 uppercase tracking-wider">
|
|
|
|
|
액션
|
|
|
|
|
</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody class="bg-white divide-y divide-gray-200">
|
|
|
|
|
@forelse($items as $item)
|
|
|
|
|
<tr>
|
|
|
|
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
|
|
|
|
{{ $item->속성 }}
|
|
|
|
|
</td>
|
|
|
|
|
<!-- 추가 컬럼들 -->
|
|
|
|
|
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium">
|
|
|
|
|
<a href="{{ route('{resource}.edit', $item->id) }}"
|
|
|
|
|
class="text-blue-600 hover:text-blue-900 mr-3">
|
|
|
|
|
수정
|
|
|
|
|
</a>
|
|
|
|
|
<button onclick="confirmDelete({{ $item->id }}, '{{ $item->name }}')"
|
|
|
|
|
class="text-red-600 hover:text-red-900">
|
|
|
|
|
삭제
|
|
|
|
|
</button>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
@empty
|
|
|
|
|
<tr>
|
|
|
|
|
<td colspan="{컬럼수}" class="px-6 py-12 text-center text-gray-500">
|
|
|
|
|
등록된 {항목명}이(가) 없습니다.
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
@endforelse
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 페이지네이션 -->
|
|
|
|
|
@include('partials.pagination', [
|
|
|
|
|
'paginator' => $items,
|
|
|
|
|
'target' => '#{resource}-table',
|
|
|
|
|
'includeForm' => '#filterForm'
|
|
|
|
|
])
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 4.3 스타일 규칙
|
|
|
|
|
|
|
|
|
|
#### 테이블 헤더
|
|
|
|
|
- **배경**: `bg-gray-50`
|
|
|
|
|
- **텍스트**: `text-sm font-semibold text-gray-700 uppercase tracking-wider`
|
|
|
|
|
- **정렬**: `text-left` (일반), `text-right` (액션)
|
|
|
|
|
- **패딩**: `px-4 py-2`
|
|
|
|
|
|
|
|
|
|
#### 테이블 본문
|
|
|
|
|
- **행 구분**: `divide-y divide-gray-200`
|
|
|
|
|
- **셀 패딩**: `px-4 py-3`
|
|
|
|
|
- **텍스트**: `text-sm text-gray-900` (일반), `text-gray-500` (보조)
|
|
|
|
|
- **공백 처리**: `whitespace-nowrap` (줄바꿈 방지)
|
|
|
|
|
|
|
|
|
|
#### 액션 버튼
|
|
|
|
|
- **수정**: `text-blue-600 hover:text-blue-900 mr-3`
|
|
|
|
|
- **삭제**: `text-red-600 hover:text-red-900`
|
|
|
|
|
|
|
|
|
|
### 4.4 배지 스타일 (선택사항)
|
|
|
|
|
|
|
|
|
|
#### Inline 스타일 배지
|
|
|
|
|
|
|
|
|
|
```blade
|
|
|
|
|
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.5rem; font-size: 0.75rem; font-weight: 500; border-radius: 0.375rem; background-color: rgb(219 234 254); color: rgb(30 64 175);">
|
|
|
|
|
{배지 텍스트}
|
|
|
|
|
</span>
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 배지 색상 시스템
|
|
|
|
|
|
|
|
|
|
| 용도 | 배경색 (RGB) | 텍스트색 (RGB) | 사용 예시 |
|
|
|
|
|
|------|-------------|---------------|----------|
|
|
|
|
|
| **Primary (파란색)** | `rgb(219 234 254)` | `rgb(30 64 175)` | Guard, 기본 태그 |
|
|
|
|
|
| **Success (초록색)** | `rgb(220 252 231)` | `rgb(21 128 61)` | 역할, 활성 상태 |
|
|
|
|
|
| **Warning (노란색)** | `rgb(254 249 195)` | `rgb(133 77 14)` | 부서, 경고 |
|
|
|
|
|
| **Danger (빨간색)** | `rgb(254 202 202)` | `rgb(153 27 27)` | 삭제 권한 |
|
|
|
|
|
| **Gray (회색)** | `rgb(243 244 246)` | `rgb(31 41 55)` | 메뉴 태그, 중립 |
|
|
|
|
|
| **Orange (주황색)** | `rgb(254 215 170)` | `rgb(154 52 18)` | 수정 권한 |
|
|
|
|
|
| **Purple (보라색)** | `rgb(233 213 255)` | `rgb(107 33 168)` | 승인 권한 |
|
|
|
|
|
| **Cyan (청록색)** | `rgb(207 250 254)` | `rgb(14 116 144)` | 내보내기 권한 |
|
|
|
|
|
|
|
|
|
|
#### Tailwind 클래스 배지 (대안)
|
|
|
|
|
|
|
|
|
|
```blade
|
|
|
|
|
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded bg-blue-100 text-blue-800">
|
|
|
|
|
{배지 텍스트}
|
|
|
|
|
</span>
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 4.5 Empty State
|
|
|
|
|
|
|
|
|
|
```blade
|
|
|
|
|
@empty
|
|
|
|
|
<tr>
|
|
|
|
|
<td colspan="{컬럼수}" class="px-6 py-12 text-center text-gray-500">
|
|
|
|
|
등록된 {항목명}이(가) 없습니다.
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
@endforelse
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 5. 페이지네이션
|
|
|
|
|
|
|
|
|
|
### 5.1 Include 방식
|
|
|
|
|
|
|
|
|
|
```blade
|
|
|
|
|
@include('partials.pagination', [
|
|
|
|
|
'paginator' => $items,
|
|
|
|
|
'target' => '#{resource}-table',
|
|
|
|
|
'includeForm' => '#filterForm'
|
|
|
|
|
])
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 5.2 페이지네이션 기능
|
|
|
|
|
|
|
|
|
|
#### 데스크톱 (>=640px)
|
|
|
|
|
- **전체 개수 표시**: "전체 N개 중 X ~ Y"
|
|
|
|
|
- **페이지당 항목 수 선택**: 10/20/30/50/100/200/500개씩
|
|
|
|
|
- **네비게이션 버튼**:
|
|
|
|
|
- 처음 (첫 페이지로)
|
|
|
|
|
- 이전 (이전 페이지로)
|
|
|
|
|
- 페이지 번호 (최대 10개 표시)
|
|
|
|
|
- 다음 (다음 페이지로)
|
|
|
|
|
- 끝 (마지막 페이지로)
|
|
|
|
|
|
|
|
|
|
#### 모바일 (<640px)
|
|
|
|
|
- 이전/다음 버튼만 표시
|
|
|
|
|
- 간소화된 네비게이션
|
|
|
|
|
|
|
|
|
|
### 5.3 JavaScript 핸들러
|
|
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
|
// 페이지 변경
|
|
|
|
|
function handlePageChange(page) {
|
|
|
|
|
const form = document.getElementById('filterForm');
|
|
|
|
|
const formData = new FormData(form);
|
|
|
|
|
formData.append('page', page);
|
|
|
|
|
|
|
|
|
|
const params = new URLSearchParams(formData).toString();
|
|
|
|
|
htmx.ajax('GET', `/api/admin/{resource}?${params}`, {
|
|
|
|
|
target: '#{resource}-table',
|
|
|
|
|
swap: 'innerHTML'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 페이지당 항목 수 변경
|
|
|
|
|
function handlePerPageChange(perPage) {
|
|
|
|
|
const form = document.getElementById('filterForm');
|
|
|
|
|
const formData = new FormData(form);
|
|
|
|
|
formData.append('per_page', perPage);
|
|
|
|
|
formData.append('page', 1); // 첫 페이지로 리셋
|
|
|
|
|
|
|
|
|
|
const params = new URLSearchParams(formData).toString();
|
|
|
|
|
htmx.ajax('GET', `/api/admin/{resource}?${params}`, {
|
|
|
|
|
target: '#{resource}-table',
|
|
|
|
|
swap: 'innerHTML'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 5.4 스타일 규칙
|
|
|
|
|
|
|
|
|
|
- **컨테이너**: `bg-white px-4 py-3 border-t border-gray-200 sm:px-6`
|
|
|
|
|
- **전체 개수**: `text-sm text-gray-700`, 숫자는 `font-medium`
|
|
|
|
|
- **페이지당 항목 선택**: `px-3 py-1 border border-gray-300 rounded-lg text-sm`
|
|
|
|
|
- **버튼 (활성)**: `bg-white text-gray-700 hover:bg-gray-50`
|
|
|
|
|
- **버튼 (비활성)**: `bg-gray-100 text-gray-400 cursor-not-allowed`
|
|
|
|
|
- **현재 페이지**: `bg-blue-50 text-blue-600`
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 6. 기술 스택
|
|
|
|
|
|
|
|
|
|
### 6.1 필수 라이브러리
|
|
|
|
|
|
|
|
|
|
```html
|
|
|
|
|
<!-- HTMX -->
|
|
|
|
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
|
|
|
|
|
|
|
|
|
<!-- Tailwind CSS (이미 레이아웃에 포함) -->
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 6.2 API 컨트롤러
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
namespace App\Http\Controllers\Api\Admin;
|
|
|
|
|
|
|
|
|
|
class {Resource}Controller extends Controller
|
|
|
|
|
{
|
|
|
|
|
public function __construct(
|
|
|
|
|
private {Resource}Service $service
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
public function index(Request $request)
|
|
|
|
|
{
|
|
|
|
|
$items = $this->service->get{Resources}(
|
|
|
|
|
$request->all(),
|
|
|
|
|
$request->input('per_page', 20)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// HTMX 요청 시 부분 HTML 반환
|
|
|
|
|
if ($request->header('HX-Request')) {
|
|
|
|
|
return view('{resource}.partials.table', compact('items'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 일반 요청 시 JSON 반환
|
|
|
|
|
return response()->json([
|
|
|
|
|
'success' => true,
|
|
|
|
|
'data' => $items->items(),
|
|
|
|
|
'meta' => [
|
|
|
|
|
'current_page' => $items->currentPage(),
|
|
|
|
|
'total' => $items->total(),
|
|
|
|
|
'per_page' => $items->perPage(),
|
|
|
|
|
'last_page' => $items->lastPage(),
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 6.3 라우트
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
// web.php (화면)
|
|
|
|
|
Route::get('/{resource}', [{Resource}Controller::class, 'index'])
|
|
|
|
|
->name('{resource}.index');
|
|
|
|
|
|
|
|
|
|
// api.php (데이터)
|
|
|
|
|
Route::prefix('api/admin')->group(function () {
|
|
|
|
|
Route::get('/{resource}', [Api\Admin\{Resource}Controller::class, 'index']);
|
|
|
|
|
Route::post('/{resource}', [Api\Admin\{Resource}Controller::class, 'store']);
|
|
|
|
|
Route::get('/{resource}/{id}', [Api\Admin\{Resource}Controller::class, 'show']);
|
|
|
|
|
Route::put('/{resource}/{id}', [Api\Admin\{Resource}Controller::class, 'update']);
|
|
|
|
|
Route::delete('/{resource}/{id}', [Api\Admin\{Resource}Controller::class, 'destroy']);
|
|
|
|
|
});
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 7. 체크리스트
|
|
|
|
|
|
|
|
|
|
### 7.1 페이지 생성 체크리스트
|
|
|
|
|
|
|
|
|
|
```markdown
|
|
|
|
|
## 새 테이블 페이지 생성 체크리스트
|
|
|
|
|
|
|
|
|
|
### 파일 구조
|
|
|
|
|
- [ ] `resources/views/{resource}/index.blade.php` 생성
|
|
|
|
|
- [ ] `resources/views/{resource}/partials/table.blade.php` 생성
|
|
|
|
|
- [ ] `app/Http/Controllers/{Resource}Controller.php` 생성
|
|
|
|
|
- [ ] `app/Http/Controllers/Api/Admin/{Resource}Controller.php` 생성
|
|
|
|
|
- [ ] `app/Services/{Resource}Service.php` 생성
|
|
|
|
|
|
|
|
|
|
### 페이지 헤더
|
|
|
|
|
- [ ] 제목 (`text-2xl font-bold text-gray-800`)
|
|
|
|
|
- [ ] 액션 버튼 (`bg-blue-600 hover:bg-blue-700`)
|
|
|
|
|
- [ ] 하단 여백 (`mb-6`)
|
|
|
|
|
|
|
|
|
|
### 필터 영역 (선택사항)
|
|
|
|
|
- [ ] 검색 입력 (`flex-1`)
|
|
|
|
|
- [ ] 드롭다운 필터 (`w-48`)
|
|
|
|
|
- [ ] 검색 버튼 (`bg-gray-600`)
|
|
|
|
|
- [ ] JavaScript 이벤트 핸들러
|
|
|
|
|
|
|
|
|
|
### 테이블
|
|
|
|
|
- [ ] HTMX 컨테이너 (`hx-get`, `hx-trigger`, `hx-include`)
|
|
|
|
|
- [ ] 로딩 스피너
|
|
|
|
|
- [ ] 테이블 헤더 (`bg-gray-50`)
|
|
|
|
|
- [ ] 테이블 본문 (`divide-y divide-gray-200`)
|
|
|
|
|
- [ ] Empty State
|
|
|
|
|
- [ ] 액션 버튼 (수정, 삭제)
|
|
|
|
|
|
|
|
|
|
### 페이지네이션
|
|
|
|
|
- [ ] `@include('partials.pagination')` 추가
|
|
|
|
|
- [ ] `handlePageChange()` 함수 구현
|
|
|
|
|
- [ ] `handlePerPageChange()` 함수 구현
|
|
|
|
|
|
|
|
|
|
### API
|
|
|
|
|
- [ ] `index()` 메서드 (HTMX + JSON 분기)
|
|
|
|
|
- [ ] Service 계층 (비즈니스 로직)
|
|
|
|
|
- [ ] FormRequest (검증)
|
|
|
|
|
- [ ] 라우트 등록
|
|
|
|
|
|
|
|
|
|
### 테스트
|
|
|
|
|
- [ ] 필터 검색 동작 확인
|
|
|
|
|
- [ ] 페이지네이션 동작 확인
|
|
|
|
|
- [ ] 액션 버튼 동작 확인
|
|
|
|
|
- [ ] 반응형 레이아웃 확인 (모바일/데스크톱)
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 7.2 스타일 일관성 체크
|
|
|
|
|
|
|
|
|
|
```markdown
|
|
|
|
|
## 스타일 일관성 체크리스트
|
|
|
|
|
|
|
|
|
|
### 색상
|
|
|
|
|
- [ ] Primary 버튼: `bg-blue-600 hover:bg-blue-700`
|
|
|
|
|
- [ ] Secondary 버튼: `bg-gray-600 hover:bg-gray-700`
|
|
|
|
|
- [ ] 텍스트: `text-gray-800` (제목), `text-gray-700` (본문), `text-gray-500` (보조)
|
|
|
|
|
|
|
|
|
|
### 간격
|
|
|
|
|
- [ ] 페이지 헤더 하단: `mb-6`
|
|
|
|
|
- [ ] 필터 영역 하단: `mb-6`
|
|
|
|
|
- [ ] 필터 요소 간격: `gap-4`
|
|
|
|
|
- [ ] 테이블 셀 패딩: `px-4 py-3` (본문), `px-4 py-2` (헤더)
|
|
|
|
|
|
|
|
|
|
### 둥근 모서리
|
|
|
|
|
- [ ] 버튼: `rounded-lg`
|
|
|
|
|
- [ ] 입력 필드: `rounded-lg`
|
|
|
|
|
- [ ] 배지: `rounded` (0.375rem)
|
|
|
|
|
- [ ] 컨테이너: `rounded-lg`
|
|
|
|
|
|
|
|
|
|
### 그림자
|
|
|
|
|
- [ ] 컨테이너: `shadow-sm`
|
|
|
|
|
- [ ] 페이지네이션: `shadow-sm`
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 8. 참고 사항
|
|
|
|
|
|
|
|
|
|
### 8.1 권한 관리 페이지 특수 기능
|
|
|
|
|
|
|
|
|
|
권한 관리 페이지는 다음과 같은 특수 기능을 포함합니다:
|
|
|
|
|
|
|
|
|
|
1. **권한명 파싱**: `menu:{menu_id}.{permission_type}` 형식 파싱
|
|
|
|
|
2. **권한 타입 배지**: V(조회), C(생성), U(수정), D(삭제), A(승인), E(내보내기), M(관리)
|
|
|
|
|
3. **메뉴 태그**: 회색 배지로 메뉴 ID 표시
|
|
|
|
|
4. **역할/부서 배지**: 여러 개 배지를 가로 나열 (`flex flex-nowrap gap-1`)
|
|
|
|
|
|
|
|
|
|
이러한 특수 기능은 다른 페이지에서 필요에 따라 적용하거나 생략할 수 있습니다.
|
|
|
|
|
|
|
|
|
|
### 8.2 성능 최적화
|
|
|
|
|
|
|
|
|
|
- **Eager Loading**: 관계 데이터를 미리 로드하여 N+1 쿼리 방지
|
|
|
|
|
```php
|
|
|
|
|
$items = Model::with(['relation1', 'relation2'])->paginate(20);
|
|
|
|
|
```
|
|
|
|
|
- **페이지네이션**: 기본값 20개, 최대 500개까지 지원
|
|
|
|
|
- **HTMX**: 부분 HTML만 교체하여 빠른 반응성 제공
|
|
|
|
|
|
|
|
|
|
### 8.3 접근성
|
|
|
|
|
|
|
|
|
|
- **시맨틱 HTML**: `<table>`, `<thead>`, `<tbody>` 사용
|
|
|
|
|
- **버튼 레이블**: 명확한 액션 설명
|
|
|
|
|
- **키보드 네비게이션**: 버튼과 링크에 포커스 가능
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 9. 예제 코드
|
|
|
|
|
|
|
|
|
|
### 9.1 최소 구현 예제
|
|
|
|
|
|
|
|
|
|
#### `resources/views/products/index.blade.php`
|
|
|
|
|
|
|
|
|
|
```blade
|
|
|
|
|
@extends('layouts.app')
|
|
|
|
|
|
|
|
|
|
@section('title', '제품 관리')
|
|
|
|
|
|
|
|
|
|
@section('content')
|
|
|
|
|
<!-- 페이지 헤더 -->
|
|
|
|
|
<div class="flex justify-between items-center mb-6">
|
|
|
|
|
<h1 class="text-2xl font-bold text-gray-800">제품 관리</h1>
|
|
|
|
|
<a href="{{ route('products.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition">
|
|
|
|
|
+ 새 제품
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 필터 영역 -->
|
|
|
|
|
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
|
|
|
|
<form id="filterForm" class="flex gap-4">
|
|
|
|
|
<div class="flex-1">
|
|
|
|
|
<input type="text"
|
|
|
|
|
name="search"
|
|
|
|
|
placeholder="제품명으로 검색..."
|
|
|
|
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
|
|
|
|
</div>
|
|
|
|
|
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition">
|
|
|
|
|
검색
|
|
|
|
|
</button>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 테이블 영역 -->
|
|
|
|
|
<div id="product-table"
|
|
|
|
|
hx-get="/api/admin/products"
|
|
|
|
|
hx-trigger="load, filterSubmit from:body"
|
|
|
|
|
hx-include="#filterForm"
|
|
|
|
|
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
|
|
|
|
|
class="bg-white rounded-lg shadow-sm overflow-hidden">
|
|
|
|
|
<div class="flex justify-center items-center p-12">
|
|
|
|
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
@endsection
|
|
|
|
|
|
|
|
|
|
@push('scripts')
|
|
|
|
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
|
|
|
|
<script>
|
|
|
|
|
document.getElementById('filterForm').addEventListener('submit', function(e) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
htmx.trigger('#product-table', 'filterSubmit');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function confirmDelete(id, name) {
|
|
|
|
|
if (confirm(`"${name}" 제품을 삭제하시겠습니까?`)) {
|
|
|
|
|
fetch(`/api/admin/products/${id}`, {
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
headers: {
|
|
|
|
|
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
|
|
|
'Accept': 'application/json'
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.then(response => response.json())
|
|
|
|
|
.then(data => {
|
|
|
|
|
if (data.success) {
|
|
|
|
|
htmx.trigger('#product-table', 'filterSubmit');
|
|
|
|
|
alert(data.message);
|
|
|
|
|
} else {
|
|
|
|
|
alert(data.message);
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch(error => {
|
|
|
|
|
alert('제품 삭제 중 오류가 발생했습니다.');
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
@endpush
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### `resources/views/products/partials/table.blade.php`
|
|
|
|
|
|
|
|
|
|
```blade
|
|
|
|
|
<div class="overflow-x-auto">
|
|
|
|
|
<table class="min-w-full divide-y divide-gray-200">
|
|
|
|
|
<thead class="bg-gray-50">
|
|
|
|
|
<tr>
|
|
|
|
|
<th class="px-4 py-2 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">ID</th>
|
|
|
|
|
<th class="px-4 py-2 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">제품명</th>
|
|
|
|
|
<th class="px-4 py-2 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">생성일</th>
|
|
|
|
|
<th class="px-4 py-2 text-right text-sm font-semibold text-gray-700 uppercase tracking-wider">액션</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody class="bg-white divide-y divide-gray-200">
|
|
|
|
|
@forelse($products as $product)
|
|
|
|
|
<tr>
|
|
|
|
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
|
|
|
|
{{ $product->id }}
|
|
|
|
|
</td>
|
|
|
|
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
|
|
|
|
{{ $product->name }}
|
|
|
|
|
</td>
|
|
|
|
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
|
|
|
|
|
{{ $product->created_at?->format('Y-m-d H:i') ?? '-' }}
|
|
|
|
|
</td>
|
|
|
|
|
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium">
|
|
|
|
|
<a href="{{ route('products.edit', $product->id) }}"
|
|
|
|
|
class="text-blue-600 hover:text-blue-900 mr-3">
|
|
|
|
|
수정
|
|
|
|
|
</a>
|
|
|
|
|
<button onclick="confirmDelete({{ $product->id }}, '{{ $product->name }}')"
|
|
|
|
|
class="text-red-600 hover:text-red-900">
|
|
|
|
|
삭제
|
|
|
|
|
</button>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
@empty
|
|
|
|
|
<tr>
|
|
|
|
|
<td colspan="4" class="px-6 py-12 text-center text-gray-500">
|
|
|
|
|
등록된 제품이 없습니다.
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
@endforelse
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
@include('partials.pagination', [
|
|
|
|
|
'paginator' => $products,
|
|
|
|
|
'target' => '#product-table',
|
|
|
|
|
'includeForm' => '#filterForm'
|
|
|
|
|
])
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
2026-02-03 08:06:35 +09:00
|
|
|
## 10. React 테이블 (Blade + Babel)
|
|
|
|
|
|
|
|
|
|
> **참조 파일**: `resources/views/barobill/etax/index.blade.php`
|
|
|
|
|
> **작성일**: 2026-02-03
|
|
|
|
|
|
|
|
|
|
Blade 템플릿 내에서 React(Babel)를 사용하는 경우, 테이블 컬럼 너비 설정 시 주의해야 할 사항이 있습니다.
|
|
|
|
|
|
|
|
|
|
### 10.1 colgroup을 사용한 컬럼 너비 지정
|
|
|
|
|
|
|
|
|
|
React에서 `table-fixed` 레이아웃과 함께 컬럼 너비를 정확하게 지정하려면 `colgroup`을 사용해야 합니다. Tailwind의 `w-[]` 클래스만으로는 정확한 너비 적용이 어려울 수 있습니다.
|
|
|
|
|
|
|
|
|
|
#### 잘못된 예시 (Tailwind 클래스만 사용)
|
|
|
|
|
|
|
|
|
|
```jsx
|
|
|
|
|
// ❌ 테이블 셀에 Tailwind 클래스만 적용 - 비율이 의도대로 안 될 수 있음
|
|
|
|
|
<table className="w-full text-sm table-fixed">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th className="w-[30%]">품목명</th>
|
|
|
|
|
<th className="w-[60px]">수량</th>
|
|
|
|
|
<th className="w-[100px]">단가</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
</table>
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 올바른 예시 (colgroup 사용)
|
|
|
|
|
|
|
|
|
|
```jsx
|
|
|
|
|
// ✅ colgroup으로 명시적 너비 지정
|
|
|
|
|
<table className="w-full text-sm" style=@{{tableLayout: 'fixed'}}>
|
|
|
|
|
<colgroup>
|
|
|
|
|
<col style=@{{width: '30%'}} /> {/* 품목명 - 가장 넓게 */}
|
|
|
|
|
<col style=@{{width: '60px'}} /> {/* 수량 - 작게 고정 */}
|
|
|
|
|
<col style=@{{width: '100px'}} /> {/* 단가 - 수량보다 넓게 */}
|
|
|
|
|
<col style=@{{width: '12%'}} /> {/* 공급가액 */}
|
|
|
|
|
<col style=@{{width: '10%'}} /> {/* 세액 */}
|
|
|
|
|
<col style=@{{width: '12%'}} /> {/* 금액 */}
|
|
|
|
|
<col style=@{{width: '70px'}} /> {/* 과세 (select) */}
|
|
|
|
|
<col style=@{{width: '40px'}} /> {/* 삭제 버튼 */}
|
|
|
|
|
</colgroup>
|
|
|
|
|
<thead className="bg-stone-100 border-b border-stone-200">
|
|
|
|
|
<tr>
|
|
|
|
|
<th className="px-3 py-2.5 text-left font-medium text-stone-700">품목명</th>
|
|
|
|
|
<th className="px-2 py-2.5 text-right font-medium text-stone-700">수량</th>
|
|
|
|
|
<th className="px-2 py-2.5 text-right font-medium text-stone-700">단가</th>
|
|
|
|
|
{/* ... */}
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
</table>
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 10.2 Blade 템플릿에서 React 스타일 객체 이스케이프
|
|
|
|
|
|
|
|
|
|
**중요**: Blade 템플릿(`.blade.php`)에서 React의 스타일 객체 `{{ }}`를 사용하면 Blade가 이를 PHP echo 구문으로 해석하여 에러가 발생합니다.
|
|
|
|
|
|
|
|
|
|
#### 에러 발생 코드
|
|
|
|
|
|
|
|
|
|
```jsx
|
|
|
|
|
// ❌ Blade가 {{ }}를 PHP 변수로 해석 → 에러 발생
|
|
|
|
|
<table style={{tableLayout: 'fixed'}}>
|
|
|
|
|
// Error: Unknown named parameter $tableLayout
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 해결 방법: `@{{ }}` 사용
|
|
|
|
|
|
|
|
|
|
```jsx
|
|
|
|
|
// ✅ @를 붙여 Blade 이스케이프 처리
|
|
|
|
|
<table style=@{{tableLayout: 'fixed'}}>
|
|
|
|
|
<col style=@{{width: '30%'}} />
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
`@{{ }}`를 사용하면 Blade가 해당 구문을 처리하지 않고 그대로 `{{ }}`로 출력하여 React/Babel이 정상적으로 해석합니다.
|
|
|
|
|
|
|
|
|
|
### 10.3 입력 테이블 컬럼 비율 가이드
|
|
|
|
|
|
|
|
|
|
품목 입력 테이블의 권장 컬럼 비율:
|
|
|
|
|
|
|
|
|
|
| 컬럼 | 너비 | 설명 |
|
|
|
|
|
|------|------|------|
|
|
|
|
|
| 품목명 | **30%** | 텍스트 입력, 가장 넓게 |
|
|
|
|
|
| 수량 | **60px** | 작은 숫자 입력, 고정 너비 |
|
|
|
|
|
| 단가 | **100px** | 금액 입력, 수량보다 넓게 |
|
|
|
|
|
| 공급가액 | **12%** | 계산된 금액 표시 |
|
|
|
|
|
| 세액 | **10%** | 계산된 금액 표시 |
|
|
|
|
|
| 금액 | **12%** | 합계 금액 표시 |
|
|
|
|
|
| 과세유형 | **70px** | select 박스 |
|
|
|
|
|
| 삭제 | **40px** | 버튼 |
|
|
|
|
|
|
|
|
|
|
#### 비율 설정 원칙
|
|
|
|
|
|
|
|
|
|
1. **입력 필드**는 내용에 맞는 적절한 너비 확보
|
|
|
|
|
2. **수량**은 보통 작은 숫자이므로 좁게 (60px)
|
|
|
|
|
3. **단가/금액**은 큰 숫자를 표시하므로 넉넉하게
|
|
|
|
|
4. **품목명**은 텍스트 입력이므로 가장 넓게 (%)
|
|
|
|
|
5. **버튼/아이콘**은 고정 픽셀 (px)
|
|
|
|
|
|
|
|
|
|
### 10.4 전체 예제 코드
|
|
|
|
|
|
|
|
|
|
```jsx
|
|
|
|
|
// Blade 템플릿 내 React 코드 (@push('scripts') 내부)
|
|
|
|
|
<table className="w-full text-sm" style=@{{tableLayout: 'fixed'}}>
|
|
|
|
|
<colgroup>
|
|
|
|
|
<col style=@{{width: '30%'}} />
|
|
|
|
|
<col style=@{{width: '60px'}} />
|
|
|
|
|
<col style=@{{width: '100px'}} />
|
|
|
|
|
<col style=@{{width: '12%'}} />
|
|
|
|
|
<col style=@{{width: '10%'}} />
|
|
|
|
|
<col style=@{{width: '12%'}} />
|
|
|
|
|
<col style=@{{width: '70px'}} />
|
|
|
|
|
<col style=@{{width: '40px'}} />
|
|
|
|
|
</colgroup>
|
|
|
|
|
<thead className="bg-stone-100 border-b border-stone-200">
|
|
|
|
|
<tr>
|
|
|
|
|
<th className="px-3 py-2.5 text-left font-medium text-stone-700">품목명</th>
|
|
|
|
|
<th className="px-2 py-2.5 text-right font-medium text-stone-700">수량</th>
|
|
|
|
|
<th className="px-2 py-2.5 text-right font-medium text-stone-700">단가</th>
|
|
|
|
|
<th className="px-2 py-2.5 text-right font-medium text-stone-700">공급가액</th>
|
|
|
|
|
<th className="px-2 py-2.5 text-right font-medium text-stone-700">세액</th>
|
|
|
|
|
<th className="px-2 py-2.5 text-right font-medium text-stone-700">금액</th>
|
|
|
|
|
<th className="px-1 py-2.5 text-center font-medium text-stone-700">과세</th>
|
|
|
|
|
<th className="px-1 py-2.5 text-center font-medium text-stone-700"></th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody className="divide-y divide-stone-100">
|
|
|
|
|
{/* 데이터 행들 */}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 11. 문서 이력
|
2025-11-26 20:40:54 +09:00
|
|
|
|
|
|
|
|
| 버전 | 날짜 | 작성자 | 변경 내용 |
|
|
|
|
|
|------|------|--------|----------|
|
|
|
|
|
| 1.0 | 2025-11-25 | Claude | 초안 작성 (권한 관리 페이지 기반) |
|
2026-02-03 08:06:35 +09:00
|
|
|
| 1.1 | 2026-02-03 | Claude | React 테이블 섹션 추가 (colgroup, Blade 이스케이프) |
|
2025-11-26 20:40:54 +09:00
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
2026-02-03 08:06:35 +09:00
|
|
|
## 12. 문의
|
2025-11-26 20:40:54 +09:00
|
|
|
|
|
|
|
|
이 문서에 대한 문의사항이나 개선 제안은 프로젝트 관리자에게 연락하세요.
|