Files
gbanyan 3e9bf153dc fix(03): replace template x-data with tbody x-data for table rendering
<template x-data> inside <tbody> is inert — browsers don't render its
children. Replace with per-member <tbody x-data> (multiple tbody is
valid HTML). Also replace x-collapse on <tr> with x-transition since
table rows don't support max-height/overflow-hidden.

UAT: all 7 tests passed via Playwright automation.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-02-13 15:02:16 +08:00

498 lines
34 KiB
PHP

<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>
@forelse ($members as $member)
<tbody class="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-800" x-data="{
noteFormOpen: false,
noteContent: '',
isSubmitting: false,
errors: {},
noteCount: {{ $member->notes_count ?? 0 }},
historyOpen: false,
notes: [],
notesLoaded: false,
isLoadingNotes: false,
searchQuery: '',
async submitNote() {
this.isSubmitting = true;
this.errors = {};
try {
const response = await axios.post('{{ route('admin.members.notes.store', $member) }}', {
content: this.noteContent
});
this.noteCount++;
this.noteContent = '';
this.noteFormOpen = false;
if (this.notesLoaded) {
this.notes.unshift(response.data.note);
}
} catch (error) {
if (error.response && error.response.status === 422) {
this.errors = error.response.data.errors || {};
}
} finally {
this.isSubmitting = false;
}
},
toggleHistory() {
this.historyOpen = !this.historyOpen;
if (!this.historyOpen) {
this.searchQuery = '';
}
if (this.historyOpen && !this.notesLoaded) {
this.loadNotes();
}
},
async loadNotes() {
this.isLoadingNotes = true;
try {
const response = await axios.get('{{ route("admin.members.notes.index", $member) }}');
this.notes = response.data.notes;
this.notesLoaded = true;
} catch (error) {
console.error('Failed to load notes:', error);
} finally {
this.isLoadingNotes = false;
}
},
get filteredNotes() {
if (!this.searchQuery.trim()) return this.notes;
const query = this.searchQuery.toLowerCase();
return this.notes.filter(note =>
note.content.toLowerCase().includes(query) ||
note.author.name.toLowerCase().includes(query)
);
},
formatDateTime(dateString) {
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return year + '年' + month + '月' + day + '日 ' + hours + ':' + minutes;
}
}">
<tr>
<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 -->
<button @click="toggleHistory()"
type="button"
:aria-expanded="historyOpen.toString()"
:aria-controls="'notes-panel-{{ $member->id }}'"
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 hover:bg-blue-200 dark:hover:bg-blue-800 cursor-pointer transition-colors">
<span x-text="noteCount"></span>
</button>
<!-- 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>
<!-- Expansion panel row -->
<tr x-show="historyOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
:id="'notes-panel-{{ $member->id }}'"
class="bg-gray-50 dark:bg-gray-900">
<td colspan="8" class="px-6 py-4">
<!-- Loading state -->
<div x-show="isLoadingNotes" class="flex justify-center py-4">
<svg class="animate-spin h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">載入中...</span>
</div>
<!-- Loaded content -->
<div x-show="!isLoadingNotes" x-cloak>
<!-- Search input (only show if notes exist) -->
<template x-if="notes.length > 0">
<div class="mb-3">
<input type="text"
x-model="searchQuery"
placeholder="搜尋備忘錄內容或作者..."
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">
</div>
</template>
<!-- Notes list -->
<template x-if="filteredNotes.length > 0">
<div class="space-y-3 max-h-64 overflow-y-auto">
<template x-for="note in filteredNotes" :key="note.id">
<div class="border-l-4 border-blue-500 dark:border-blue-400 pl-3 py-2">
<p class="text-sm text-gray-900 dark:text-gray-100 whitespace-pre-line" x-text="note.content"></p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
<span x-text="note.author.name"></span>
<span class="mx-1">&middot;</span>
<span x-text="formatDateTime(note.created_at)"></span>
</p>
</div>
</template>
</div>
</template>
<!-- Empty state: no notes at all -->
<template x-if="notesLoaded && notes.length === 0">
<p class="text-sm text-gray-500 dark:text-gray-400 py-2">尚無備註</p>
</template>
<!-- No search results -->
<template x-if="notes.length > 0 && filteredNotes.length === 0">
<p class="text-sm text-gray-500 dark:text-gray-400 py-2">找不到符合的備忘錄</p>
</template>
</div>
</td>
</tr>
</tbody>
@empty
<tbody class="bg-white dark:bg-gray-800">
<tr>
<td colspan="8" class="px-4 py-4 text-sm text-gray-500 dark:text-gray-400">
找不到會員。
</td>
</tr>
</tbody>
@endforelse
</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>