feat(admin): smart dropdown positioning, filter & pagination fixes

- Add getDropdownPosition() to handle table dropdown placement dynamically
- Remove overflow-hidden on admin-card so dropdowns aren't clipped
- Fix totalPages calculation using total/limit instead of missing API field
- Add role/status filters + full pagination to member page
- Create .agents/admin-ui-pitfalls.md for future reference
This commit is contained in:
ngthanhvu
2026-08-10 00:22:54 -04:00
parent f6532d992d
commit 2dac79e5f5
3 changed files with 199 additions and 26 deletions
+135
View File
@@ -0,0 +1,135 @@
# Admin Panel UI Pitfalls & Patterns
## 1. Dropdowns Inside Tables — Always Use Smart Positioning
**❌ Đừng bao giờ viết cứng** `top-full mt-1` hoặc `bottom-full mb-1` cho dropdown trong table:
```vue
<!-- SAI luôn mở xuống dưới, bị cắt hàng cuối -->
<div class="absolute right-0 top-full z-50 mt-1 bg-white">
<!-- SAI luôn mở lên trên, che hàng đầu -->
<div class="absolute right-0 bottom-full mb-1 z-50 bg-white">
```
**✅ Đúng — dùng computed position dựa trên index:**
```ts
// Helper để dropdown luôn hiển thị bên trong vùng nhìn thấy
function getDropdownPosition(index: number) {
const total = items.value?.length || 0
// Chỉ mở lên trên khi còn ít nhất 2 hàng phía dưới
if (total <= 2 || index < total - 2) {
return 'top-full mt-1'
}
return 'bottom-full mb-1'
}
```
```vue
<template>
<td class="px-4 py-3.5 text-center relative">
<div class="inline-flex">
<button @click="menuOpen = menuOpen === id ? null : id">
<AppIcon name="ellipsis-vertical" />
</button>
<Transition name="dropdown-fade">
<div v-if="menuOpen === id" :class="[
'absolute right-0 min-w-40 rounded-lg border border-slate-200 bg-white py-1 shadow-xl z-50',
getDropdownPosition(items.indexOf(item))
]">
<!-- content -->
</div>
</Transition>
</div>
</td>
</template>
```
### Rule Checklist:
- [ ] Mỗi cell có dropdown cần `relative` wrapper
- [ ] Hàm `getDropdownPosition` nhận index từ `items.indexOf(item)`
- [ ] Dùng dynamic class binding, KHÔNG hardcode vị trí
- [ ] Kiểm tra khi item nằm ở hàng cuối cùng của bảng
---
## 2. Table Clipping — Remove `overflow-hidden` on Card Wrappers
Khi table có dropdown/menu, `.admin-card overflow-hidden` sẽ cắt mất dropdown khi nó mở ra ngoài vùng chứa.
**✅ Luôn bỏ `overflow-hidden` hoặc override bằng `overflow-visible!`:**
```vue
<!-- Đúng dropdown không bị cắt -->
<div class="admin-card overflow-visible!">
```
> **Lưu ý Tailwind v4 syntax**: dùng `overflow-visible!` chứ KHÔNG phải `!overflow-visible`. Linter sẽ báo lỗi nếu viết sai.
---
## 3. Admin Light Mode — Hardcoded Colors Need Override
Các component admin dùng hardcode màu dark mode (`bg-[#131418]`, `text-white`, `border-white/6`) không tự động chuyển khi xem trong light mode. Cần xử lý theo thứ tự ưu tiên:
### Thứ tự xử lý đúng:
1. **Preference 1**: Đổi trực tiếp template sang light-mode colors (`bg-white`, `border-slate-200`, `text-slate-900`)
2. **Preference 2**: Thêm rule CSS vào `.agents/admin-light-palette.md` nếu nhiều nơi share style
3. **Last resort**: Dùng CSS selector override trong layout `<style>` — nhưng chỉ cho teleported elements (modal/dialog teleport ra body)
**❌ Không làm**: Viết CSS selector phức tạp như `[class*="bg-"][class*="#131418"]` — khó maintain và dễ break.
**✅ Làm**: Thay màu trong template ngay từ đầu:
```vue
<!-- Dark mode (default) -->
<div class="bg-[#131418] border-white/6 text-white">
<!-- Light mode nên code sẵn hai bộ màu hoặc toggle -->
<div class="dark:bg-[#131418] dark:border-white/6 dark:text-white
bg-white border-slate-200 text-slate-900">
```
> **Quy tắc**: Nếu class đã hardcode giá trị hex/dark theme, đó là bug waiting to happen trong light mode. Code luôn với cả 2 palette hoặc dùng utility classes Tailwind chuẩn.
---
## 4. Admin Toggle Button — Use Brand Color
Component `AdminToggle.vue` cần dùng màu brand `#095DF2` (CineK Blue):
```vue
:class="model ? 'bg-[#095DF2]' : 'bg-slate-600'"
focus:ring-[#095DF2]/50
```
Không dùng vàng (`yellow-400`), xanh lá, hay bất kỳ màu nào khác cho state active.
---
## 5. Admin Sort Icons — Yellow → Blue
Sort icons đang sort phải dùng màu brand `#095DF2`:
```vue
:class="sortBy === 'column' ? 'text-[#095DF2]' : 'opacity-50'"
```
Không dùng `text-yellow-400`. Tương tự cho pagination active state.
---
## 6. Common Pattern Reference
| Component | Class Pattern | Notes |
|-----------|--------------|-------|
| Sort icon active | `text-[#095DF2]` | Not yellow |
| Pagination active | `border-[#095DF2] bg-[#095DF2] text-white` | Center text white |
| Dropdown bg | `bg-white border-slate-200` | For light mode compatibility |
| Dropdown hover | `hover:bg-slate-100` | Not `hover:bg-white/5` |
| Delete menu item | `text-red-500 hover:bg-red-50` | Red-500 for light mode |
| Table header border | `border-bottom: 3px solid #eeee` | Inline style for precise control |
| Table row divider | `divide-y divide-[#eeee]` | Light gray separator |
| Cell padding | `px-4 py-3.5` | Consistent across all admin tables |
+18 -6
View File
@@ -123,6 +123,17 @@ async function handleDeleteAll() {
const movies = computed(() => data.value?.items || []) const movies = computed(() => data.value?.items || [])
const totalPages = computed(() => data.value?.totalPages || 1) const totalPages = computed(() => data.value?.totalPages || 1)
// Helper để dropdown luôn hiển thị bên trong vùng nhìn thấy
function getDropdownPosition(index: number) {
const total = movies.value?.length || 0
// Chỉ mở lên trên khi còn ít nhất 2 hàng phía dưới
// Còn lại mở xuống dưới (mặc định)
if (total <= 2 || index < total - 2) {
return 'top-full mt-1'
}
return 'bottom-full mb-1'
}
const visiblePages = computed(() => { const visiblePages = computed(() => {
const total = totalPages.value const total = totalPages.value
const current = currentPage.value const current = currentPage.value
@@ -233,7 +244,7 @@ const syncSourceOptions = [
</div> </div>
</div> </div>
<div class="admin-card overflow-hidden"> <div class="admin-card overflow-visible!">
<div class="grid grid-cols-1 gap-3 border-b border-white/6 p-4 <div class="grid grid-cols-1 gap-3 border-b border-white/6 p-4
md:grid-cols-2 md:grid-cols-2
xl:grid-cols-[minmax(280px,1fr)_180px_160px_150px] xl:grid-cols-[minmax(280px,1fr)_180px_160px_150px]
@@ -390,8 +401,8 @@ const syncSourceOptions = [
@update:model-value="(val: boolean) => { movie.active = val; toggleActive(movie) }" /> @update:model-value="(val: boolean) => { movie.active = val; toggleActive(movie) }" />
</div> </div>
</td> </td>
<td class="px-4 py-3.5 text-center"> <td class="px-4 py-3.5 text-center relative">
<div class="relative inline-flex"> <div class="inline-flex">
<button type="button" class="grid size-9 place-items-center rounded-lg <button type="button" class="grid size-9 place-items-center rounded-lg
text-zinc-500 transition text-zinc-500 transition
hover:bg-slate-100 hover:text-zinc-700" title="Thao tác" hover:bg-slate-100 hover:text-zinc-700" title="Thao tác"
@@ -399,9 +410,10 @@ const syncSourceOptions = [
<AppIcon name="ellipsis-vertical" class="size-5 stroke-[2.5]" /> <AppIcon name="ellipsis-vertical" class="size-5 stroke-[2.5]" />
</button> </button>
<Transition name="dropdown-fade"> <Transition name="dropdown-fade">
<div v-if="menuOpen === movie.id" class="absolute right-0 top-full z-50 mt-1 min-w-40 <div v-if="menuOpen === movie.id" :class="[
rounded-lg border border-slate-200 'absolute right-0 min-w-40 rounded-lg border border-slate-200 bg-white py-1 shadow-xl z-50',
bg-white py-1 shadow-xl"> getDropdownPosition(movies.indexOf(movie))
]">
<NuxtLink :to="`/admin/phim/${movie.id}`" class="flex w-full items-center gap-2 px-3 py-2 <NuxtLink :to="`/admin/phim/${movie.id}`" class="flex w-full items-center gap-2 px-3 py-2
text-sm text-slate-700 transition text-sm text-slate-700 transition
hover:bg-slate-100 hover:text-slate-900" @click="menuOpen = null"> hover:bg-slate-100 hover:text-slate-900" @click="menuOpen = null">
+46 -20
View File
@@ -66,7 +66,23 @@ async function deleteMember(member: any) {
} }
const members = computed(() => data.value?.items || []) const members = computed(() => data.value?.items || [])
const totalPages = computed(() => data.value?.totalPages || 1) const totalPages = computed(() => {
const limit = 20
return Math.ceil((data.value?.total || 1) / limit)
})
// 3-dot menu state
const menuOpen = ref<number | null>(null)
// Helper để dropdown luôn hiển thị bên trong vùng nhìn thấy
function getDropdownPosition(index: number) {
const total = members.value?.length || 0
// Chỉ mở lên trên khi còn ít nhất 2 hàng phía dưới
if (total <= 2 || index < total - 2) {
return 'top-full mt-1'
}
return 'bottom-full mb-1'
}
const visiblePages = computed(() => { const visiblePages = computed(() => {
const total = totalPages.value const total = totalPages.value
@@ -108,7 +124,7 @@ const visiblePages = computed(() => {
<p class="admin-section-subtitle mt-1">Quản tài khoản thành viên CineK ({{ data?.total || 0 }} thành viên)</p> <p class="admin-section-subtitle mt-1">Quản tài khoản thành viên CineK ({{ data?.total || 0 }} thành viên)</p>
</div> </div>
<div class="admin-card overflow-hidden"> <div class="admin-card overflow-visible!">
<div class="grid grid-cols-1 gap-3 border-b border-white/6 p-4 <div class="grid grid-cols-1 gap-3 border-b border-white/6 p-4
md:grid-cols-2 xl:grid-cols-[minmax(280px,1fr)_180px_160px] xl:items-center"> md:grid-cols-2 xl:grid-cols-[minmax(280px,1fr)_180px_160px] xl:items-center">
<div class="relative min-w-0 w-full"> <div class="relative min-w-0 w-full">
@@ -149,17 +165,9 @@ const visiblePages = computed(() => {
<tr v-for="member in members" :key="member.id" class="transition hover:bg-white/2"> <tr v-for="member in members" :key="member.id" class="transition hover:bg-white/2">
<td class="px-4 py-3.5 text-center text-sm font-semibold text-slate-500">{{ members.indexOf(member) + 1 }} <td class="px-4 py-3.5 text-center text-sm font-semibold text-slate-500">{{ members.indexOf(member) + 1 }}
</td> </td>
<td class="min-w-0 px-4 py-3.5 text-center"> <td class="px-4 py-3.5 text-center">
<div class="flex items-center gap-3 justify-center"> <p class="truncate text-sm font-bold text-white">{{ member.name || 'Chưa có tên' }}</p>
<div <p class="truncate text-xs text-slate-400">{{ member.email }}</p>
class="grid size-9 place-items-center rounded-full bg-linear-to-br from-slate-700 to-slate-800 text-sm font-bold text-slate-300 shrink-0">
{{ (member.name || member.email || '?').charAt(0).toUpperCase() }}
</div>
<div class="min-w-0">
<p class="truncate text-sm font-bold text-white">{{ member.name || 'Chưa có tên' }}</p>
<p class="truncate text-xs text-slate-400">{{ member.email }}</p>
</div>
</div>
</td> </td>
<td class="px-4 py-3.5 text-center"> <td class="px-4 py-3.5 text-center">
<select :value="member.role" <select :value="member.role"
@@ -178,13 +186,31 @@ const visiblePages = computed(() => {
{{ new Date(member.createdAt).toLocaleDateString('vi-VN') }} {{ new Date(member.createdAt).toLocaleDateString('vi-VN') }}
</td> </td>
<td class="px-4 py-3.5 text-center"> <td class="px-4 py-3.5 text-center">
<button type="button" <div class="relative inline-flex">
class="grid size-8 place-items-center rounded-lg text-red-400 transition hover:bg-red-500/10" <button type="button"
:disabled="currentUser?.id === member.id" class="grid size-9 place-items-center rounded-lg text-zinc-500 transition
:title="currentUser?.id === member.id ? 'Không thể xoá chính mình' : 'Xoá thành viên'" hover:bg-slate-100 hover:text-zinc-700" title="Thao tác"
@click="deleteMember(member)"> @click="menuOpen = menuOpen === member.id ? null : member.id">
<AppIcon name="trash" class="size-4" /> <AppIcon name="ellipsis-vertical" class="size-5 stroke-[2.5]" />
</button> </button>
<Transition name="dropdown-fade">
<div v-if="menuOpen === member.id" :class="[
'absolute right-0 min-w-40 rounded-lg border border-slate-200 bg-white py-1 shadow-xl z-50',
getDropdownPosition(members.indexOf(member))
]">
<button type="button"
v-if="currentUser?.id !== member.id"
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-red-500 transition hover:bg-red-50"
@click="menuOpen = null; deleteMember(member)">
<AppIcon name="trash" class="size-4" />
Xoá thành viên
</button>
<div v-else class="px-3 py-2 text-xs text-slate-400">
Không thể xoá chính mình
</div>
</div>
</Transition>
</div>
</td> </td>
</tr> </tr>
<tr v-if="!members.length"> <tr v-if="!members.length">