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:
@@ -15,7 +15,7 @@ class MemberNoteController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function index(Member $member)
|
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]);
|
return response()->json(['notes' => $notes]);
|
||||||
}
|
}
|
||||||
|
|||||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -4,6 +4,9 @@
|
|||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"@alpinejs/collapse": "^3.15.8"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.2",
|
"@tailwindcss/forms": "^0.5.2",
|
||||||
"alpinejs": "^3.4.2",
|
"alpinejs": "^3.4.2",
|
||||||
@@ -28,6 +31,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||||
|
|||||||
@@ -14,5 +14,8 @@
|
|||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.31",
|
||||||
"tailwindcss": "^3.1.0",
|
"tailwindcss": "^3.1.0",
|
||||||
"vite": "^5.0.0"
|
"vite": "^5.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@alpinejs/collapse": "^3.15.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import './bootstrap';
|
import './bootstrap';
|
||||||
|
|
||||||
import Alpine from 'alpinejs';
|
import Alpine from 'alpinejs';
|
||||||
|
import collapse from '@alpinejs/collapse';
|
||||||
|
|
||||||
|
Alpine.plugin(collapse);
|
||||||
window.Alpine = Alpine;
|
window.Alpine = Alpine;
|
||||||
|
|
||||||
Alpine.start();
|
Alpine.start();
|
||||||
|
|||||||
@@ -193,22 +193,30 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-800">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-800">
|
||||||
@forelse ($members as $member)
|
@forelse ($members as $member)
|
||||||
<tr x-data="{
|
<template x-data="{
|
||||||
noteFormOpen: false,
|
noteFormOpen: false,
|
||||||
noteContent: '',
|
noteContent: '',
|
||||||
isSubmitting: false,
|
isSubmitting: false,
|
||||||
errors: {},
|
errors: {},
|
||||||
noteCount: {{ $member->notes_count ?? 0 }},
|
noteCount: {{ $member->notes_count ?? 0 }},
|
||||||
|
historyOpen: false,
|
||||||
|
notes: [],
|
||||||
|
notesLoaded: false,
|
||||||
|
isLoadingNotes: false,
|
||||||
|
searchQuery: '',
|
||||||
async submitNote() {
|
async submitNote() {
|
||||||
this.isSubmitting = true;
|
this.isSubmitting = true;
|
||||||
this.errors = {};
|
this.errors = {};
|
||||||
try {
|
try {
|
||||||
await axios.post('{{ route('admin.members.notes.store', $member) }}', {
|
const response = await axios.post('{{ route('admin.members.notes.store', $member) }}', {
|
||||||
content: this.noteContent
|
content: this.noteContent
|
||||||
});
|
});
|
||||||
this.noteCount++;
|
this.noteCount++;
|
||||||
this.noteContent = '';
|
this.noteContent = '';
|
||||||
this.noteFormOpen = false;
|
this.noteFormOpen = false;
|
||||||
|
if (this.notesLoaded) {
|
||||||
|
this.notes.unshift(response.data.note);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.response && error.response.status === 422) {
|
if (error.response && error.response.status === 422) {
|
||||||
this.errors = error.response.data.errors || {};
|
this.errors = error.response.data.errors || {};
|
||||||
@@ -216,8 +224,47 @@
|
|||||||
} finally {
|
} finally {
|
||||||
this.isSubmitting = false;
|
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">
|
<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">
|
<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>
|
||||||
@@ -255,9 +302,13 @@
|
|||||||
<td class="px-4 py-3 text-sm">
|
<td class="px-4 py-3 text-sm">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<!-- Note count badge -->
|
<!-- 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 x-text="noteCount"></span>
|
||||||
</span>
|
</button>
|
||||||
<!-- Toggle button -->
|
<!-- Toggle button -->
|
||||||
<button
|
<button
|
||||||
@click="noteFormOpen = !noteFormOpen"
|
@click="noteFormOpen = !noteFormOpen"
|
||||||
@@ -312,6 +363,61 @@
|
|||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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
|
@empty
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8" class="px-4 py-4 text-sm text-gray-500 dark:text-gray-400">
|
<td colspan="8" class="px-4 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
|||||||
Reference in New Issue
Block a user