Files
usher-manage-stack/resources/views/admin/members/index.blade.php
gbanyan e760bbbfc2 feat(02-01): add inline note UI to member list
- Add Alpine.js x-data scope to each member row with noteFormOpen, noteContent, isSubmitting, errors, noteCount
- Add submitNote() async method calling axios.post to admin.members.notes.store route
- Add note count badge with reactive x-text binding to noteCount (initialized from withCount)
- Add toggle button to expand/collapse inline note form
- Add inline form with textarea, cancel button, and submit button
- Submit button disabled when isSubmitting or noteContent is empty
- Loading state toggles between 儲存 and 儲存中...
- Validation errors display in Traditional Chinese via x-show and x-text
- Cancel button clears content, closes form, and resets errors
- Add 備忘錄 column header between 狀態 and 操作
- Update empty state colspan from 7 to 8
- Add x-cloak CSS to prevent flash of unstyled content
- All elements include dark mode classes (dark:*)
2026-02-13 12:32:58 +08:00

386 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<x-app-layout>
<x-slot name="header">
<h2 class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200">
會員管理
</h2>
</x-slot>
@push('styles')
<style>[x-cloak] { display: none !important; }</style>
@endpush
<div class="py-12">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<form method="GET" action="{{ route('admin.members.index') }}" class="mb-4 space-y-4" role="search" aria-label="搜尋和篩選會員">
<div>
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
依姓名、電子郵件、電話、Line ID 或身分證號搜尋
</label>
<input
type="text"
name="search"
id="search"
value="{{ $filters['search'] ?? '' }}"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 sm:text-sm dark:bg-gray-900 dark:text-gray-300"
placeholder="輸入搜尋關鍵字..."
>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
在姓名、電子郵件、電話號碼、Line ID 和身分證號中搜尋
</p>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
會員資格狀態
</label>
<select
id="status"
name="status"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 sm:text-sm dark:bg-gray-900 dark:text-gray-300"
>
<option value="">所有</option>
<option value="active" @selected(($filters['status'] ?? '') === 'active')>使用中</option>
<option value="expired" @selected(($filters['status'] ?? '') === 'expired')>已過期</option>
<option value="expiring_soon" @selected(($filters['status'] ?? '') === 'expiring_soon')>即將到期30天內</option>
</select>
</div>
<div>
<label for="payment_status" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
付款狀態
</label>
<select
id="payment_status"
name="payment_status"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 sm:text-sm dark:bg-gray-900 dark:text-gray-300"
>
<option value="">所有</option>
<option value="has_payments" @selected(($filters['payment_status'] ?? '') === 'has_payments')>有付款記錄</option>
<option value="no_payments" @selected(($filters['payment_status'] ?? '') === 'no_payments')>無付款記錄</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
日期範圍
</label>
<button
type="button"
onclick="document.getElementById('dateFilters').classList.toggle('hidden')"
class="mt-1 inline-flex w-full items-center justify-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-600 focus:ring-offset-2 dark:focus:ring-offset-gray-800"
>
切換日期篩選
</button>
</div>
</div>
<div id="dateFilters" class="{{ (($filters['started_from'] ?? '') || ($filters['started_to'] ?? '')) ? '' : 'hidden' }} grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="started_from" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
加入起始
</label>
<input
type="date"
name="started_from"
id="started_from"
value="{{ $filters['started_from'] ?? '' }}"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 sm:text-sm dark:bg-gray-900 dark:text-gray-300"
>
</div>
<div>
<label for="started_to" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
加入結束
</label>
<input
type="date"
name="started_to"
id="started_to"
value="{{ $filters['started_to'] ?? '' }}"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-700 shadow-sm focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 sm:text-sm dark:bg-gray-900 dark:text-gray-300"
>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 justify-between">
<div class="flex gap-2">
<button type="submit" class="inline-flex items-center rounded-md border border-transparent bg-indigo-600 dark:bg-indigo-500 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 dark:hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-600 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
套用篩選
</button>
<a href="{{ route('admin.members.export', request()->only('search','status')) }}" class="inline-flex items-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-600 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
匯出CSV
</a>
</div>
<div class="flex gap-2">
<a href="{{ route('admin.members.create') }}" class="inline-flex items-center rounded-md border border-transparent bg-green-600 dark:bg-green-500 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 dark:hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-500 dark:focus:ring-green-600 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
新增會員
</a>
<a href="{{ route('admin.members.import-form') }}" class="inline-flex items-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-600 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
匯入CSV
</a>
</div>
</div>
</form>
<!-- Batch Actions Toolbar (Hidden by default) -->
<div id="batchActions" class="hidden mb-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-md border border-gray-200 dark:border-gray-600 flex items-center justify-between">
<div class="flex items-center space-x-4">
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">
<span id="selectedCount">0</span> 已選擇
</span>
<!-- Batch Delete -->
<form id="batchDeleteForm" action="{{ route('admin.members.batch-destroy') }}" method="POST" onsubmit="return confirm('您確定要刪除選取的會員嗎?');">
@csrf
<div id="batchDeleteInputs"></div>
<button type="submit" class="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 text-sm font-medium">
刪除所選
</button>
</form>
<span class="text-gray-300 dark:text-gray-500">|</span>
<!-- Batch Update Status -->
<form id="batchStatusForm" action="{{ route('admin.members.batch-update-status') }}" method="POST" class="flex items-center space-x-2">
@csrf
<div id="batchStatusInputs"></div>
<select name="status" required class="text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-md shadow-sm focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600">
<option value="">設定狀態...</option>
<option value="active">活躍</option>
<option value="pending">待審核</option>
<option value="expired">已過期</option>
<option value="suspended">已停權</option>
</select>
<button type="submit" class="text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 text-sm font-medium">
更新
</button>
</form>
</div>
</div>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700" role="table">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th scope="col" class="px-4 py-3 text-left">
<input type="checkbox" id="selectAll" class="rounded border-gray-300 dark:border-gray-600 text-indigo-600 shadow-sm focus:ring-indigo-500 dark:focus:ring-indigo-600 dark:bg-gray-800">
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300">
名稱
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300">
電子郵件
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300">
角色
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300">
會員資格到期
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300">
狀態
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300">
備忘錄
</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300">
<span class="sr-only">操作</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-800">
@forelse ($members as $member)
<tr x-data="{
noteFormOpen: false,
noteContent: '',
isSubmitting: false,
errors: {},
noteCount: {{ $member->notes_count ?? 0 }},
async submitNote() {
this.isSubmitting = true;
this.errors = {};
try {
await axios.post('{{ route('admin.members.notes.store', $member) }}', {
content: this.noteContent
});
this.noteCount++;
this.noteContent = '';
this.noteFormOpen = false;
} catch (error) {
if (error.response && error.response.status === 422) {
this.errors = error.response.data.errors || {};
}
} finally {
this.isSubmitting = false;
}
}
}">
<td class="px-4 py-3">
<input type="checkbox" name="selected_ids[]" value="{{ $member->id }}" class="member-checkbox rounded border-gray-300 dark:border-gray-600 text-indigo-600 shadow-sm focus:ring-indigo-500 dark:focus:ring-indigo-600 dark:bg-gray-700">
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
{{ $member->full_name }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
{{ $member->email }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
@if($member->user && $member->user->roles->isNotEmpty())
<div class="flex flex-wrap gap-1">
@foreach($member->user->roles as $role)
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200">
{{ $role->name }}
</span>
@endforeach
</div>
@else
<span class="text-gray-500 dark:text-gray-400">-</span>
@endif
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
@if ($member->membership_expires_at)
{{ $member->membership_expires_at->toDateString() }}
@else
<span class="text-gray-500 dark:text-gray-400">未設定</span>
@endif
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm">
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 {{ $member->membership_status_badge }}">
{{ $member->membership_status_label }}
</span>
</td>
<td class="px-4 py-3 text-sm">
<div class="flex items-center gap-2">
<!-- Note count badge -->
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200">
<span x-text="noteCount"></span>
</span>
<!-- Toggle button -->
<button
@click="noteFormOpen = !noteFormOpen"
type="button"
class="text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400"
:title="noteFormOpen ? '收合' : '新增備忘錄'"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
</div>
<!-- Inline note form -->
<div x-show="noteFormOpen" x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
class="mt-2">
<form @submit.prevent="submitNote()">
<textarea
x-model="noteContent"
rows="2"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-indigo-500 dark:focus:border-indigo-500 focus:ring-indigo-500 dark:focus:ring-indigo-500 sm:text-sm dark:bg-gray-700 dark:text-gray-200"
:class="{ 'border-red-500 dark:border-red-400': errors.content }"
placeholder="輸入備忘錄..."
></textarea>
<p x-show="errors.content" x-text="errors.content?.[0]"
class="mt-1 text-xs text-red-600 dark:text-red-400"></p>
<div class="mt-1 flex justify-end gap-2">
<button
type="button"
@click="noteFormOpen = false; noteContent = ''; errors = {};"
class="inline-flex items-center rounded-md px-2.5 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
>
取消
</button>
<button
type="submit"
:disabled="isSubmitting || noteContent.trim() === ''"
class="inline-flex items-center rounded-md border border-transparent bg-indigo-600 dark:bg-indigo-500 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-indigo-700 dark:hover:bg-indigo-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!isSubmitting">儲存</span>
<span x-show="isSubmitting">儲存中...</span>
</button>
</div>
</form>
</div>
</td>
<td class="whitespace-nowrap px-4 py-3 text-right text-sm font-medium">
<a href="{{ route('admin.members.show', $member) }}" class="text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300">
檢視
</a>
</td>
</tr>
@empty
<tr>
<td colspan="8" class="px-4 py-4 text-sm text-gray-500 dark:text-gray-400">
找不到會員。
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $members->links() }}
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const selectAll = document.getElementById('selectAll');
const checkboxes = document.querySelectorAll('.member-checkbox');
const batchActions = document.getElementById('batchActions');
const selectedCount = document.getElementById('selectedCount');
const batchDeleteInputs = document.getElementById('batchDeleteInputs');
const batchStatusInputs = document.getElementById('batchStatusInputs');
function updateBatchUI() {
const selected = Array.from(checkboxes).filter(cb => cb.checked);
selectedCount.textContent = selected.length;
if (selected.length > 0) {
batchActions.classList.remove('hidden');
} else {
batchActions.classList.add('hidden');
}
// Update hidden inputs for forms
batchDeleteInputs.innerHTML = '';
batchStatusInputs.innerHTML = '';
selected.forEach(cb => {
const input1 = document.createElement('input');
input1.type = 'hidden';
input1.name = 'ids[]';
input1.value = cb.value;
batchDeleteInputs.appendChild(input1);
const input2 = document.createElement('input');
input2.type = 'hidden';
input2.name = 'ids[]';
input2.value = cb.value;
batchStatusInputs.appendChild(input2);
});
}
selectAll.addEventListener('change', function() {
checkboxes.forEach(cb => cb.checked = selectAll.checked);
updateBatchUI();
});
checkboxes.forEach(cb => {
cb.addEventListener('change', function() {
updateBatchUI();
selectAll.checked = Array.from(checkboxes).every(c => c.checked);
});
});
});
</script>
</x-app-layout>