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:
2026-02-13 12:59:03 +08:00
parent 14bab518dd
commit c0ebbdbe20
5 changed files with 125 additions and 5 deletions

View File

@@ -15,7 +15,7 @@ class MemberNoteController extends Controller
*/
public function index(Member $member)
{
$notes = $member->notes()->with('author')->get();
$notes = $member->notes()->with('author:id,name')->latest('created_at')->get();
return response()->json(['notes' => $notes]);
}

9
package-lock.json generated
View File

@@ -4,6 +4,9 @@
"requires": true,
"packages": {
"": {
"dependencies": {
"@alpinejs/collapse": "^3.15.8"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.2",
"alpinejs": "^3.4.2",
@@ -28,6 +31,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@alpinejs/collapse": {
"version": "3.15.8",
"resolved": "https://registry.npmjs.org/@alpinejs/collapse/-/collapse-3.15.8.tgz",
"integrity": "sha512-zZhD8DHdHuzGFe8+cHNH99K//oFutzKwcy6vagydb3KFlTzmqxTnHZo5sSV81lAazhV7qKsYCKtNV14tR9QkJw==",
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",

View File

@@ -14,5 +14,8 @@
"postcss": "^8.4.31",
"tailwindcss": "^3.1.0",
"vite": "^5.0.0"
},
"dependencies": {
"@alpinejs/collapse": "^3.15.8"
}
}

View File

@@ -1,7 +1,9 @@
import './bootstrap';
import Alpine from 'alpinejs';
import collapse from '@alpinejs/collapse';
Alpine.plugin(collapse);
window.Alpine = Alpine;
Alpine.start();

View File

@@ -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">&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>
</template>
@empty
<tr>
<td colspan="8" class="px-4 py-4 text-sm text-gray-500 dark:text-gray-400">