feat: Phase 4-1 테넌트 관리 프론트엔드 구현
- 라우트 등록 (web.php: Blade 화면, api.php: HTMX API) - Blade 뷰 생성 (index, create, edit) - Partial 템플릿 생성 (table with pagination) - HTMX 통합 (비동기 CRUD, 필터링, 페이지네이션) - Pure Tailwind CSS 스타일링 - 실시간 검색 및 상태 필터 - 삭제/복원 기능 구현
This commit is contained in:
192
resources/views/tenants/create.blade.php
Normal file
192
resources/views/tenants/create.blade.php
Normal file
@@ -0,0 +1,192 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '테넌트 생성')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto max-w-4xl">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">테넌트 생성</h1>
|
||||
<a href="{{ route('tenants.index') }}" class="text-gray-600 hover:text-gray-800">
|
||||
← 목록으로
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 폼 영역 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<form id="tenantForm"
|
||||
hx-post="/api/admin/tenants"
|
||||
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
|
||||
hx-swap="none">
|
||||
|
||||
<!-- 기본 정보 -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">기본 정보</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
회사명 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="company_name" required
|
||||
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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
테넌트 코드 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="code" required
|
||||
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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">이메일</label>
|
||||
<input type="email" name="email"
|
||||
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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">전화번호</label>
|
||||
<input type="text" name="phone"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- 회사 정보 -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">회사 정보</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">사업자등록번호</label>
|
||||
<input type="text" name="business_num"
|
||||
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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">법인등록번호</label>
|
||||
<input type="text" name="corp_reg_no"
|
||||
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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">대표자명</label>
|
||||
<input type="text" name="ceo_name"
|
||||
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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">팩스</label>
|
||||
<input type="text" name="fax"
|
||||
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="col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">주소</label>
|
||||
<input type="text" name="address"
|
||||
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="col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">홈페이지</label>
|
||||
<input type="url" name="homepage"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- 구독 정보 -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">구독 정보</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
상태 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select name="tenant_st_code" required
|
||||
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="trial">트라이얼</option>
|
||||
<option value="active">활성</option>
|
||||
<option value="suspended">정지</option>
|
||||
<option value="expired">만료</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">결제 유형</label>
|
||||
<select name="billing_tp_code"
|
||||
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>
|
||||
<option value="monthly">월간</option>
|
||||
<option value="yearly">연간</option>
|
||||
<option value="free">무료</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">최대 사용자 수</label>
|
||||
<input type="number" name="max_users" value="10" min="1"
|
||||
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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">트라이얼 종료일</label>
|
||||
<input type="datetime-local" name="trial_ends_at"
|
||||
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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">구독 만료일</label>
|
||||
<input type="datetime-local" name="expires_at"
|
||||
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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">마지막 결제일</label>
|
||||
<input type="datetime-local" name="last_paid_at"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- 관리 메모 -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">관리자 메모</h2>
|
||||
<textarea name="admin_memo" rows="3"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 버튼 영역 -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="{{ route('tenants.index') }}"
|
||||
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition">
|
||||
취소
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition">
|
||||
생성
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script>
|
||||
// HTMX 응답 처리
|
||||
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
if (event.detail.target.id === 'tenantForm') {
|
||||
const response = JSON.parse(event.detail.xhr.response);
|
||||
if (response.success) {
|
||||
alert(response.message);
|
||||
window.location.href = response.redirect;
|
||||
} else {
|
||||
alert('오류: ' + (response.message || '테넌트 생성에 실패했습니다.'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 에러 처리
|
||||
document.body.addEventListener('htmx:responseError', function(event) {
|
||||
if (event.detail.xhr.status === 422) {
|
||||
const errors = JSON.parse(event.detail.xhr.response).errors;
|
||||
let errorMsg = '입력 오류:\n';
|
||||
for (let field in errors) {
|
||||
errorMsg += '- ' + errors[field].join('\n') + '\n';
|
||||
}
|
||||
alert(errorMsg);
|
||||
} else {
|
||||
alert('서버 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
218
resources/views/tenants/edit.blade.php
Normal file
218
resources/views/tenants/edit.blade.php
Normal file
@@ -0,0 +1,218 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '테넌트 수정')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto max-w-4xl">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">테넌트 수정</h1>
|
||||
<a href="{{ route('tenants.index') }}" class="text-gray-600 hover:text-gray-800">
|
||||
← 목록으로
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 폼 영역 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<form id="tenantForm"
|
||||
hx-put="/api/admin/tenants/{{ $tenant->id }}"
|
||||
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
|
||||
hx-swap="none">
|
||||
|
||||
<!-- 기본 정보 -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">기본 정보</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
회사명 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="company_name" value="{{ old('company_name', $tenant->company_name) }}" required
|
||||
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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
테넌트 코드 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="code" value="{{ old('code', $tenant->code) }}" required
|
||||
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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">이메일</label>
|
||||
<input type="email" name="email" value="{{ old('email', $tenant->email) }}"
|
||||
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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">전화번호</label>
|
||||
<input type="text" name="phone" value="{{ old('phone', $tenant->phone) }}"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- 회사 정보 -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">회사 정보</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">사업자등록번호</label>
|
||||
<input type="text" name="business_num" value="{{ old('business_num', $tenant->business_num) }}"
|
||||
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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">법인등록번호</label>
|
||||
<input type="text" name="corp_reg_no" value="{{ old('corp_reg_no', $tenant->corp_reg_no) }}"
|
||||
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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">대표자명</label>
|
||||
<input type="text" name="ceo_name" value="{{ old('ceo_name', $tenant->ceo_name) }}"
|
||||
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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">팩스</label>
|
||||
<input type="text" name="fax" value="{{ old('fax', $tenant->fax) }}"
|
||||
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="col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">주소</label>
|
||||
<input type="text" name="address" value="{{ old('address', $tenant->address) }}"
|
||||
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="col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">홈페이지</label>
|
||||
<input type="url" name="homepage" value="{{ old('homepage', $tenant->homepage) }}"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- 구독 정보 -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">구독 정보</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
상태 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select name="tenant_st_code" required
|
||||
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="trial" {{ old('tenant_st_code', $tenant->tenant_st_code) === 'trial' ? 'selected' : '' }}>트라이얼</option>
|
||||
<option value="active" {{ old('tenant_st_code', $tenant->tenant_st_code) === 'active' ? 'selected' : '' }}>활성</option>
|
||||
<option value="suspended" {{ old('tenant_st_code', $tenant->tenant_st_code) === 'suspended' ? 'selected' : '' }}>정지</option>
|
||||
<option value="expired" {{ old('tenant_st_code', $tenant->tenant_st_code) === 'expired' ? 'selected' : '' }}>만료</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">결제 유형</label>
|
||||
<select name="billing_tp_code"
|
||||
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>
|
||||
<option value="monthly" {{ old('billing_tp_code', $tenant->billing_tp_code) === 'monthly' ? 'selected' : '' }}>월간</option>
|
||||
<option value="yearly" {{ old('billing_tp_code', $tenant->billing_tp_code) === 'yearly' ? 'selected' : '' }}>연간</option>
|
||||
<option value="free" {{ old('billing_tp_code', $tenant->billing_tp_code) === 'free' ? 'selected' : '' }}>무료</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">최대 사용자 수</label>
|
||||
<input type="number" name="max_users" value="{{ old('max_users', $tenant->max_users) }}" min="1"
|
||||
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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">트라이얼 종료일</label>
|
||||
<input type="datetime-local" name="trial_ends_at"
|
||||
value="{{ old('trial_ends_at', $tenant->trial_ends_at?->format('Y-m-d\TH:i')) }}"
|
||||
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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">구독 만료일</label>
|
||||
<input type="datetime-local" name="expires_at"
|
||||
value="{{ old('expires_at', $tenant->expires_at?->format('Y-m-d\TH:i')) }}"
|
||||
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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">마지막 결제일</label>
|
||||
<input type="datetime-local" name="last_paid_at"
|
||||
value="{{ old('last_paid_at', $tenant->last_paid_at?->format('Y-m-d\TH:i')) }}"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- 관리 메모 -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">관리자 메모</h2>
|
||||
<textarea name="admin_memo" rows="3"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">{{ old('admin_memo', $tenant->admin_memo) }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- 통계 정보 -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">통계</h2>
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div class="bg-gray-50 p-4 rounded-lg text-center">
|
||||
<div class="text-2xl font-bold text-blue-600">{{ $tenant->users_count ?? 0 }}</div>
|
||||
<div class="text-sm text-gray-600">사용자</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 p-4 rounded-lg text-center">
|
||||
<div class="text-2xl font-bold text-green-600">{{ $tenant->departments_count ?? 0 }}</div>
|
||||
<div class="text-sm text-gray-600">부서</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 p-4 rounded-lg text-center">
|
||||
<div class="text-2xl font-bold text-purple-600">{{ $tenant->menus_count ?? 0 }}</div>
|
||||
<div class="text-sm text-gray-600">메뉴</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 p-4 rounded-lg text-center">
|
||||
<div class="text-2xl font-bold text-orange-600">{{ $tenant->roles_count ?? 0 }}</div>
|
||||
<div class="text-sm text-gray-600">역할</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 버튼 영역 -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="{{ route('tenants.index') }}"
|
||||
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition">
|
||||
취소
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition">
|
||||
수정
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script>
|
||||
// HTMX 응답 처리
|
||||
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
if (event.detail.target.id === 'tenantForm') {
|
||||
const response = JSON.parse(event.detail.xhr.response);
|
||||
if (response.success) {
|
||||
alert(response.message);
|
||||
window.location.href = response.redirect;
|
||||
} else {
|
||||
alert('오류: ' + (response.message || '테넌트 수정에 실패했습니다.'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 에러 처리
|
||||
document.body.addEventListener('htmx:responseError', function(event) {
|
||||
if (event.detail.xhr.status === 422) {
|
||||
const errors = JSON.parse(event.detail.xhr.response).errors;
|
||||
let errorMsg = '입력 오류:\n';
|
||||
for (let field in errors) {
|
||||
errorMsg += '- ' + errors[field].join('\n') + '\n';
|
||||
}
|
||||
alert(errorMsg);
|
||||
} else {
|
||||
alert('서버 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
110
resources/views/tenants/index.blade.php
Normal file
110
resources/views/tenants/index.blade.php
Normal file
@@ -0,0 +1,110 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '테넌트 관리')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">테넌트 관리</h1>
|
||||
<a href="{{ route('tenants.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>
|
||||
|
||||
<!-- 상태 필터 -->
|
||||
<div class="w-48">
|
||||
<select name="tenant_st_code" 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>
|
||||
<option value="trial">트라이얼</option>
|
||||
<option value="active">활성</option>
|
||||
<option value="suspended">정지</option>
|
||||
<option value="expired">만료</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 삭제된 항목 포함 -->
|
||||
<div class="w-48">
|
||||
<select name="trashed" 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>
|
||||
<option value="with">삭제 포함</option>
|
||||
<option value="only">삭제만</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>
|
||||
|
||||
<!-- 테이블 영역 (HTMX로 로드) -->
|
||||
<div id="tenant-table"
|
||||
hx-get="/api/admin/tenants"
|
||||
hx-trigger="load, filterSubmit from:body"
|
||||
hx-include="#filterForm"
|
||||
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>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script>
|
||||
// 폼 제출 시 HTMX 이벤트 트리거
|
||||
document.getElementById('filterForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
htmx.trigger('#tenant-table', 'filterSubmit');
|
||||
});
|
||||
|
||||
// HTMX 응답 처리
|
||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||
if (event.detail.target.id === 'tenant-table') {
|
||||
const response = JSON.parse(event.detail.xhr.response);
|
||||
if (response.html) {
|
||||
event.detail.target.innerHTML = response.html;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 삭제 확인
|
||||
window.confirmDelete = function(id, name) {
|
||||
if (confirm(`"${name}" 테넌트를 삭제하시겠습니까?`)) {
|
||||
htmx.ajax('DELETE', `/api/admin/tenants/${id}`, {
|
||||
target: '#tenant-table',
|
||||
swap: 'none'
|
||||
}).then(() => {
|
||||
htmx.trigger('#tenant-table', 'filterSubmit');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 복원 확인
|
||||
window.confirmRestore = function(id, name) {
|
||||
if (confirm(`"${name}" 테넌트를 복원하시겠습니까?`)) {
|
||||
htmx.ajax('POST', `/api/admin/tenants/${id}/restore`, {
|
||||
target: '#tenant-table',
|
||||
swap: 'none'
|
||||
}).then(() => {
|
||||
htmx.trigger('#tenant-table', 'filterSubmit');
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@endpush
|
||||
148
resources/views/tenants/partials/table.blade.php
Normal file
148
resources/views/tenants/partials/table.blade.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">회사명</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">코드</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">상태</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">이메일</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">전화번호</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">사용자</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">부서</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">메뉴</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">역할</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">생성일</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@forelse($tenants as $tenant)
|
||||
<tr class="{{ $tenant->deleted_at ? 'bg-gray-100' : '' }}">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ $tenant->id }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900">{{ $tenant->company_name }}</div>
|
||||
@if($tenant->ceo_name)
|
||||
<div class="text-sm text-gray-500">대표: {{ $tenant->ceo_name }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ $tenant->code }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
{{ $tenant->status_badge_color === 'success' ? 'bg-green-100 text-green-800' : '' }}
|
||||
{{ $tenant->status_badge_color === 'warning' ? 'bg-yellow-100 text-yellow-800' : '' }}
|
||||
{{ $tenant->status_badge_color === 'error' ? 'bg-red-100 text-red-800' : '' }}">
|
||||
{{ $tenant->status_label }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ $tenant->email ?? '-' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ $tenant->phone ?? '-' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-center text-gray-900">
|
||||
{{ $tenant->users_count ?? 0 }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-center text-gray-900">
|
||||
{{ $tenant->departments_count ?? 0 }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-center text-gray-900">
|
||||
{{ $tenant->menus_count ?? 0 }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-center text-gray-900">
|
||||
{{ $tenant->roles_count ?? 0 }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ $tenant->created_at->format('Y-m-d') }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
@if($tenant->deleted_at)
|
||||
<!-- 삭제된 항목 -->
|
||||
<button onclick="confirmRestore({{ $tenant->id }}, '{{ $tenant->company_name }}')"
|
||||
class="text-green-600 hover:text-green-900 mr-3">
|
||||
복원
|
||||
</button>
|
||||
@if(auth()->user()?->is_super_admin)
|
||||
<button onclick="confirmForceDelete({{ $tenant->id }}, '{{ $tenant->company_name }}')"
|
||||
class="text-red-600 hover:text-red-900">
|
||||
영구삭제
|
||||
</button>
|
||||
@endif
|
||||
@else
|
||||
<!-- 활성 항목 -->
|
||||
<a href="{{ route('tenants.edit', $tenant->id) }}"
|
||||
class="text-blue-600 hover:text-blue-900 mr-3">
|
||||
수정
|
||||
</a>
|
||||
<button onclick="confirmDelete({{ $tenant->id }}, '{{ $tenant->company_name }}')"
|
||||
class="text-red-600 hover:text-red-900">
|
||||
삭제
|
||||
</button>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="12" class="px-6 py-12 text-center text-gray-500">
|
||||
등록된 테넌트가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 페이지네이션 -->
|
||||
@if($tenants->hasPages())
|
||||
<div class="bg-white px-4 py-3 border-t border-gray-200 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 flex justify-between sm:hidden">
|
||||
@if($tenants->onFirstPage())
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-400 bg-gray-100 cursor-not-allowed">
|
||||
이전
|
||||
</span>
|
||||
@else
|
||||
<button hx-get="{{ $tenants->previousPageUrl() }}"
|
||||
hx-target="#tenant-table"
|
||||
hx-include="#filterForm"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
이전
|
||||
</button>
|
||||
@endif
|
||||
|
||||
@if($tenants->hasMorePages())
|
||||
<button hx-get="{{ $tenants->nextPageUrl() }}"
|
||||
hx-target="#tenant-table"
|
||||
hx-include="#filterForm"
|
||||
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
다음
|
||||
</button>
|
||||
@else
|
||||
<span class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-400 bg-gray-100 cursor-not-allowed">
|
||||
다음
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-700">
|
||||
전체 <span class="font-medium">{{ $tenants->total() }}</span>개 중
|
||||
<span class="font-medium">{{ $tenants->firstItem() }}</span>
|
||||
~
|
||||
<span class="font-medium">{{ $tenants->lastItem() }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||
{{ $tenants->links() }}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
30
routes/api.php
Normal file
30
routes/api.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\Admin\TenantController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| API Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| HTMX 요청 시 HTML 반환, 일반 요청 시 JSON 반환
|
||||
|
|
||||
*/
|
||||
|
||||
Route::middleware('auth:sanctum')->prefix('admin')->name('api.admin.')->group(function () {
|
||||
|
||||
// 테넌트 관리 API
|
||||
Route::prefix('tenants')->name('tenants.')->group(function () {
|
||||
Route::get('/', [TenantController::class, 'index'])->name('index');
|
||||
Route::post('/', [TenantController::class, 'store'])->name('store');
|
||||
Route::get('/{id}', [TenantController::class, 'show'])->name('show');
|
||||
Route::put('/{id}', [TenantController::class, 'update'])->name('update');
|
||||
Route::delete('/{id}', [TenantController::class, 'destroy'])->name('destroy');
|
||||
|
||||
// 추가 액션
|
||||
Route::post('/{id}/restore', [TenantController::class, 'restore'])->name('restore');
|
||||
Route::delete('/{id}/force', [TenantController::class, 'forceDestroy'])->name('forceDestroy');
|
||||
Route::get('/stats', [TenantController::class, 'stats'])->name('stats');
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,14 @@
|
||||
// 테넌트 전환
|
||||
Route::post('/tenant/switch', [TenantController::class, 'switch'])->name('tenant.switch');
|
||||
|
||||
// 대시보드 (임시)
|
||||
// 테넌트 관리 (Blade 화면만)
|
||||
Route::prefix('tenants')->name('tenants.')->group(function () {
|
||||
Route::get('/', [TenantController::class, 'index'])->name('index');
|
||||
Route::get('/create', [TenantController::class, 'create'])->name('create');
|
||||
Route::get('/{id}/edit', [TenantController::class, 'edit'])->name('edit');
|
||||
});
|
||||
|
||||
// 대시보드
|
||||
Route::get('/dashboard', function () {
|
||||
return view('dashboard.index');
|
||||
})->name('dashboard');
|
||||
|
||||
Reference in New Issue
Block a user