feat: 實作使用者管理與公共事業費分頁標準化
This commit is contained in:
@@ -20,9 +20,27 @@ class UserController extends Controller
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$sortBy = $request->input('sort_by', 'id');
|
||||
$sortOrder = $request->input('sort_order', 'asc');
|
||||
$search = $request->input('search');
|
||||
$roleId = $request->input('role');
|
||||
|
||||
$query = User::with(['roles:id,name,display_name']);
|
||||
|
||||
// Handle Search
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%")
|
||||
->orWhere('username', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Handle Role Filter
|
||||
if ($roleId && $roleId !== 'all') {
|
||||
$query->whereHas('roles', function ($q) use ($roleId) {
|
||||
$q->where('id', $roleId);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle sorting
|
||||
if (in_array($sortBy, ['name', 'created_at'])) {
|
||||
$query->orderBy($sortBy, $sortOrder);
|
||||
@@ -31,10 +49,12 @@ class UserController extends Controller
|
||||
}
|
||||
|
||||
$users = $query->paginate($perPage)->withQueryString();
|
||||
$roles = Role::select('id', 'name', 'display_name')->get();
|
||||
|
||||
return Inertia::render('Admin/User/Index', [
|
||||
'users' => $users,
|
||||
'filters' => $request->only(['per_page', 'sort_by', 'sort_order']),
|
||||
'roles' => $roles,
|
||||
'filters' => $request->only(['per_page', 'sort_by', 'sort_order', 'search', 'role']),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,14 +45,14 @@ class UtilityFeeController extends Controller
|
||||
$query->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
$fees = $query->paginate($request->input('per_page', 15))->withQueryString();
|
||||
$fees = $query->paginate($request->input('per_page', 10))->withQueryString();
|
||||
|
||||
$availableCategories = UtilityFee::distinct()->pluck('category');
|
||||
|
||||
return Inertia::render('UtilityFee/Index', [
|
||||
'fees' => $fees,
|
||||
'availableCategories' => $availableCategories,
|
||||
'filters' => $request->only(['search', 'category', 'date_start', 'date_end', 'sort_field', 'sort_direction']),
|
||||
'filters' => $request->only(['search', 'category', 'date_start', 'date_end', 'sort_field', 'sort_direction', 'per_page']),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,25 @@ export default function Pagination({ links, className }: PaginationProps) {
|
||||
|
||||
const isPrevious = label === "Previous";
|
||||
const isNext = label === "Next";
|
||||
const activeIndex = links.findIndex(l => l.active);
|
||||
|
||||
// Tablet/Mobile visibility logic (< md):
|
||||
// Show: Previous, Next, Active, and up to 2 neighbors (Total ~5 numeric pages)
|
||||
// Hide others on small screens (hidden md:flex)
|
||||
// User requested: "small than 800... display 5 pages"
|
||||
const isVisibleOnTablet =
|
||||
isPrevious ||
|
||||
isNext ||
|
||||
link.active ||
|
||||
key === activeIndex - 1 ||
|
||||
key === activeIndex + 1 ||
|
||||
key === activeIndex - 2 ||
|
||||
key === activeIndex + 2;
|
||||
|
||||
const baseClasses = cn(
|
||||
isVisibleOnTablet ? "flex" : "hidden md:flex",
|
||||
"h-9 items-center justify-center rounded-md border px-3 text-sm"
|
||||
);
|
||||
|
||||
// 如果是 Previous/Next 但沒有 URL,則不渲染(或者渲染為 disabled)
|
||||
if ((isPrevious || isNext) && !link.url) {
|
||||
@@ -32,7 +51,8 @@ export default function Pagination({ links, className }: PaginationProps) {
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
"flex h-9 items-center justify-center rounded-md border border-input bg-transparent px-3 text-sm text-muted-foreground opacity-50 cursor-not-allowed",
|
||||
baseClasses,
|
||||
"border-input bg-transparent text-muted-foreground opacity-50 cursor-not-allowed",
|
||||
isPrevious || isNext ? "px-2" : ""
|
||||
)}
|
||||
>
|
||||
@@ -49,7 +69,8 @@ export default function Pagination({ links, className }: PaginationProps) {
|
||||
href={link.url}
|
||||
preserveScroll
|
||||
className={cn(
|
||||
"flex h-9 items-center justify-center rounded-md border px-3 text-sm transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||
baseClasses,
|
||||
"transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||
link.active
|
||||
? "border-primary bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"
|
||||
: "border-input bg-transparent text-foreground",
|
||||
@@ -63,7 +84,10 @@ export default function Pagination({ links, className }: PaginationProps) {
|
||||
) : (
|
||||
<div
|
||||
key={key}
|
||||
className="flex h-9 items-center justify-center rounded-md border border-input bg-transparent px-3 text-sm text-foreground"
|
||||
className={cn(
|
||||
baseClasses,
|
||||
"border-input bg-transparent text-foreground"
|
||||
)}
|
||||
>
|
||||
<span dangerouslySetInnerHTML={{ __html: link.label }} />
|
||||
</div>
|
||||
|
||||
@@ -339,21 +339,32 @@ export default function ActivityLogIndex({ activities, filters, subject_types, u
|
||||
from={activities.from}
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
<Pagination
|
||||
links={activities.links}
|
||||
total={activities.total}
|
||||
current_page={activities.current_page}
|
||||
last_page={activities.last_page}
|
||||
per_page={perPage}
|
||||
onPerPageChange={handlePerPageChange}
|
||||
<div className="mt-4 flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>每頁顯示</span>
|
||||
<SearchableSelect
|
||||
value={perPage}
|
||||
onValueChange={handlePerPageChange}
|
||||
options={[
|
||||
{ label: "10", value: "10" },
|
||||
{ label: "20", value: "20" },
|
||||
{ label: "50", value: "50" },
|
||||
{ label: "100", value: "100" }
|
||||
]}
|
||||
className="w-[100px] h-8"
|
||||
showSearch={false}
|
||||
/>
|
||||
<span>筆</span>
|
||||
</div>
|
||||
<div className="w-full md:w-auto flex justify-center md:justify-end">
|
||||
<Pagination links={activities.links} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ActivityDetailDialog
|
||||
isOpen={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
activity={selectedActivity}
|
||||
/>
|
||||
</AuthenticatedLayout>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { Users, Plus, Pencil, Trash2, Mail, Shield, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { Users, Plus, Pencil, Trash2, Mail, Shield, ArrowUpDown, ArrowUp, ArrowDown, Search, X } from 'lucide-react';
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { debounce } from "lodash";
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
@@ -58,11 +60,16 @@ interface Props {
|
||||
per_page?: string;
|
||||
sort_by?: string;
|
||||
sort_order?: 'asc' | 'desc';
|
||||
search?: string;
|
||||
role?: string;
|
||||
};
|
||||
roles: Role[];
|
||||
}
|
||||
|
||||
export default function UserIndex({ users, filters }: Props) {
|
||||
export default function UserIndex({ users, roles, filters }: Props) {
|
||||
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
|
||||
const [searchTerm, setSearchTerm] = useState(filters.search || "");
|
||||
const [roleFilter, setRoleFilter] = useState<string>(filters.role || "all");
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const [deleteName, setDeleteName] = useState<string>('');
|
||||
const [modelOpen, setModelOpen] = useState(false);
|
||||
@@ -88,11 +95,46 @@ export default function UserIndex({ users, filters }: Props) {
|
||||
setPerPage(value);
|
||||
router.get(
|
||||
route('users.index'),
|
||||
{ ...filters, per_page: value },
|
||||
{ ...filters, per_page: value, search: searchTerm, role: roleFilter },
|
||||
{ preserveState: false, replace: true, preserveScroll: true }
|
||||
);
|
||||
};
|
||||
|
||||
// Debounced Search Handler
|
||||
const debouncedSearch = useCallback(
|
||||
debounce((term: string, role: string) => {
|
||||
router.get(
|
||||
route('users.index'),
|
||||
{ ...filters, search: term, role: role },
|
||||
{ preserveState: true, replace: true, preserveScroll: true }
|
||||
);
|
||||
}, 500),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchChange = (term: string) => {
|
||||
setSearchTerm(term);
|
||||
debouncedSearch(term, roleFilter);
|
||||
};
|
||||
|
||||
const handleRoleChange = (value: string) => {
|
||||
setRoleFilter(value);
|
||||
router.get(
|
||||
route('users.index'),
|
||||
{ ...filters, search: searchTerm, role: value },
|
||||
{ preserveState: false, replace: true, preserveScroll: true }
|
||||
);
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchTerm("");
|
||||
router.get(
|
||||
route('users.index'),
|
||||
{ ...filters, search: "", role: roleFilter },
|
||||
{ preserveState: true, replace: true, preserveScroll: true }
|
||||
);
|
||||
};
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
let newSortBy: string | undefined = field;
|
||||
let newSortOrder: 'asc' | 'desc' | undefined = 'asc';
|
||||
@@ -145,15 +187,55 @@ export default function UserIndex({ users, filters }: Props) {
|
||||
管理系統使用者帳號與角色分配
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="搜尋姓名、Email、帳號..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className="pl-10 pr-10"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={handleClearSearch}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role Filter */}
|
||||
<SearchableSelect
|
||||
value={roleFilter}
|
||||
onValueChange={handleRoleChange}
|
||||
options={[
|
||||
{ label: "全部角色", value: "all" },
|
||||
...roles.map((role) => ({ label: role.display_name, value: role.id.toString() }))
|
||||
]}
|
||||
placeholder="角色篩選"
|
||||
className="w-full md:w-[180px]"
|
||||
/>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<Can permission="users.create">
|
||||
<Link href={route('users.create')}>
|
||||
<Button className="button-filled-primary">
|
||||
<Link href={route('users.create')} className="w-full md:w-auto">
|
||||
<Button className="w-full md:w-auto button-filled-primary">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新增使用者
|
||||
</Button>
|
||||
</Link>
|
||||
</Can>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
|
||||
@@ -74,6 +74,7 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
|
||||
const [dateStart, setDateStart] = useState(filters.date_start || "");
|
||||
const [dateEnd, setDateEnd] = useState(filters.date_end || "");
|
||||
const [dateRangeType, setDateRangeType] = useState("custom");
|
||||
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
@@ -108,18 +109,36 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
|
||||
date_end: dateEnd,
|
||||
sort_field: sortField,
|
||||
sort_direction: sortDirection,
|
||||
per_page: perPage,
|
||||
},
|
||||
{ preserveState: true }
|
||||
);
|
||||
};
|
||||
|
||||
const handlePerPageChange = (value: string) => {
|
||||
setPerPage(value);
|
||||
router.get(
|
||||
route("utility-fees.index"),
|
||||
{
|
||||
search: searchTerm,
|
||||
category: categoryFilter,
|
||||
date_start: dateStart,
|
||||
date_end: dateEnd,
|
||||
sort_field: sortField,
|
||||
sort_direction: sortDirection,
|
||||
per_page: value,
|
||||
},
|
||||
{ preserveState: false, preserveScroll: true }
|
||||
);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSearchTerm("");
|
||||
setCategoryFilter("all");
|
||||
setDateStart("");
|
||||
setDateEnd("");
|
||||
setDateRangeType("custom");
|
||||
router.get(route("utility-fees.index"));
|
||||
router.get(route("utility-fees.index"), { per_page: perPage }, { preserveState: false });
|
||||
};
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
@@ -147,6 +166,7 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
|
||||
date_end: dateEnd,
|
||||
sort_field: newField,
|
||||
sort_direction: newDirection,
|
||||
per_page: perPage,
|
||||
},
|
||||
{ preserveState: true }
|
||||
);
|
||||
@@ -469,7 +489,26 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="border-t p-4">
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>每頁顯示</span>
|
||||
<SearchableSelect
|
||||
value={perPage}
|
||||
onValueChange={handlePerPageChange}
|
||||
options={[
|
||||
{ label: "10", value: "10" },
|
||||
{ label: "20", value: "20" },
|
||||
{ label: "50", value: "50" },
|
||||
{ label: "100", value: "100" }
|
||||
]}
|
||||
className="w-[100px] h-8"
|
||||
showSearch={false}
|
||||
/>
|
||||
<span>筆</span>
|
||||
</div>
|
||||
<div className="w-full sm:w-auto flex justify-center sm:justify-end">
|
||||
<Pagination links={fees.links} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user