feat(03-01): add expandable note history panel with search
- Install @alpinejs/collapse plugin for smooth expand/collapse animation
- Fix controller ordering: notes now explicitly ordered newest first via latest('created_at')
- Note count badge is now clickable button to toggle history panel
- Add expansion panel row with loading state, search filter, empty state
- Search filters notes by content or author name (client-side)
- Panel collapses cleanly, search query resets on close
- Cache sync: new notes from inline form appear in history immediately
- Display format: author name and formatted datetime (YYYY年MM月DD日 HH:mm)
- Empty state shows '尚無備註', no-results shows '找不到符合的備忘錄'
This commit is contained in:
@@ -193,22 +193,30 @@
|
||||
</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="{
|
||||
<template 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 {
|
||||
await axios.post('{{ route('admin.members.notes.store', $member) }}', {
|
||||
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 || {};
|
||||
@@ -216,8 +224,47 @@
|
||||
} 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>
|
||||
@@ -255,9 +302,13 @@
|
||||
<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">
|
||||
<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>
|
||||
</span>
|
||||
</button>
|
||||
<!-- Toggle button -->
|
||||
<button
|
||||
@click="noteFormOpen = !noteFormOpen"
|
||||
@@ -312,6 +363,61 @@
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Expansion panel row -->
|
||||
<tr x-show="historyOpen" x-collapse
|
||||
: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">·</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>
|
||||
</template>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="8" class="px-4 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
|
||||
Reference in New Issue
Block a user