Files
usher-manage-stack/resources/views/admin/members/index.blade.php
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
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>
@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>