Files
sam-manage/docs/TABLE_LAYOUT_STANDARD.md
hskwon 7546771ee5 feat(mng): 개인 권한 관리 통합 매트릭스 구현
- 역할/부서/개인 권한을 통합하여 최종 유효 권한 표시
- 권한 소스별 색상 구분 UI (보라=역할, 파랑=부서, 녹색=개인허용, 빨강=개인거부)
- 스마트 토글 로직 (상속된 권한 오버라이드 지원)
- UserPermissionService: getRolePermissions(), getDepartmentPermissions(), getPersonalOverrides()
- 사용자 ID 뱃지 스타일 개선
2025-11-26 20:40:54 +09:00

20 KiB

MNG 테이블 레이아웃 표준

기준 페이지: /permissions (권한 관리) 작성일: 2025-11-25 목적: mng 프로젝트의 모든 테이블 페이지에서 일관된 레이아웃과 UX를 제공


📋 목차

  1. 페이지 구조
  2. 페이지 헤더
  3. 필터 영역
  4. 테이블 구조
  5. 페이지네이션
  6. 기술 스택
  7. 체크리스트

1. 페이지 구조

1.1 전체 레이아웃 순서

@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 기본 구조

<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 기본 구조

<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 이벤트

document.getElementById('filterForm').addEventListener('submit', function(e) {
    e.preventDefault();
    htmx.trigger('#{resource}-table', 'filterSubmit');
});

4. 테이블 구조

4.1 HTMX 컨테이너

<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)

<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 스타일 배지

<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 클래스 배지 (대안)

<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

@empty
<tr>
    <td colspan="{컬럼수}" class="px-6 py-12 text-center text-gray-500">
        등록된 {항목명}이(가) 없습니다.
    </td>
</tr>
@endforelse

5. 페이지네이션

5.1 Include 방식

@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 핸들러

// 페이지 변경
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 필수 라이브러리

<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>

<!-- Tailwind CSS (이미 레이아웃에 포함) -->

6.2 API 컨트롤러

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 라우트

// 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 페이지 생성 체크리스트

## 새 테이블 페이지 생성 체크리스트

### 파일 구조
- [ ] `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 스타일 일관성 체크

## 스타일 일관성 체크리스트

### 색상
- [ ] 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 쿼리 방지
    $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

@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

<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'
])

10. 문서 이력

버전 날짜 작성자 변경 내용
1.0 2025-11-25 Claude 초안 작성 (권한 관리 페이지 기반)

11. 문의

이 문서에 대한 문의사항이나 개선 제안은 프로젝트 관리자에게 연락하세요.