feat: 實作使用者管理與公共事業費分頁標準化
This commit is contained in:
@@ -20,9 +20,27 @@ class UserController extends Controller
|
|||||||
$perPage = $request->input('per_page', 10);
|
$perPage = $request->input('per_page', 10);
|
||||||
$sortBy = $request->input('sort_by', 'id');
|
$sortBy = $request->input('sort_by', 'id');
|
||||||
$sortOrder = $request->input('sort_order', 'asc');
|
$sortOrder = $request->input('sort_order', 'asc');
|
||||||
|
$search = $request->input('search');
|
||||||
|
$roleId = $request->input('role');
|
||||||
|
|
||||||
$query = User::with(['roles:id,name,display_name']);
|
$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
|
// Handle sorting
|
||||||
if (in_array($sortBy, ['name', 'created_at'])) {
|
if (in_array($sortBy, ['name', 'created_at'])) {
|
||||||
$query->orderBy($sortBy, $sortOrder);
|
$query->orderBy($sortBy, $sortOrder);
|
||||||
@@ -31,10 +49,12 @@ class UserController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$users = $query->paginate($perPage)->withQueryString();
|
$users = $query->paginate($perPage)->withQueryString();
|
||||||
|
$roles = Role::select('id', 'name', 'display_name')->get();
|
||||||
|
|
||||||
return Inertia::render('Admin/User/Index', [
|
return Inertia::render('Admin/User/Index', [
|
||||||
'users' => $users,
|
'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');
|
$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');
|
$availableCategories = UtilityFee::distinct()->pluck('category');
|
||||||
|
|
||||||
return Inertia::render('UtilityFee/Index', [
|
return Inertia::render('UtilityFee/Index', [
|
||||||
'fees' => $fees,
|
'fees' => $fees,
|
||||||
'availableCategories' => $availableCategories,
|
'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 isPrevious = label === "Previous";
|
||||||
const isNext = label === "Next";
|
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)
|
// 如果是 Previous/Next 但沒有 URL,則不渲染(或者渲染為 disabled)
|
||||||
if ((isPrevious || isNext) && !link.url) {
|
if ((isPrevious || isNext) && !link.url) {
|
||||||
@@ -32,7 +51,8 @@ export default function Pagination({ links, className }: PaginationProps) {
|
|||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className={cn(
|
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" : ""
|
isPrevious || isNext ? "px-2" : ""
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -49,7 +69,8 @@ export default function Pagination({ links, className }: PaginationProps) {
|
|||||||
href={link.url}
|
href={link.url}
|
||||||
preserveScroll
|
preserveScroll
|
||||||
className={cn(
|
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
|
link.active
|
||||||
? "border-primary bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"
|
? "border-primary bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"
|
||||||
: "border-input bg-transparent text-foreground",
|
: "border-input bg-transparent text-foreground",
|
||||||
@@ -63,7 +84,10 @@ export default function Pagination({ links, className }: PaginationProps) {
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
key={key}
|
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 }} />
|
<span dangerouslySetInnerHTML={{ __html: link.label }} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -339,21 +339,32 @@ export default function ActivityLogIndex({ activities, filters, subject_types, u
|
|||||||
from={activities.from}
|
from={activities.from}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-4 flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
<Pagination
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
links={activities.links}
|
<span>每頁顯示</span>
|
||||||
total={activities.total}
|
<SearchableSelect
|
||||||
current_page={activities.current_page}
|
value={perPage}
|
||||||
last_page={activities.last_page}
|
onValueChange={handlePerPageChange}
|
||||||
per_page={perPage}
|
options={[
|
||||||
onPerPageChange={handlePerPageChange}
|
{ 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ActivityDetailDialog
|
<ActivityDetailDialog
|
||||||
isOpen={detailOpen}
|
open={detailOpen}
|
||||||
onClose={() => setDetailOpen(false)}
|
onOpenChange={setDetailOpen}
|
||||||
activity={selectedActivity}
|
activity={selectedActivity}
|
||||||
/>
|
/>
|
||||||
</AuthenticatedLayout>
|
</AuthenticatedLayout>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||||
import { Head, Link, router } from '@inertiajs/react';
|
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 { Button } from '@/Components/ui/button';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -58,11 +60,16 @@ interface Props {
|
|||||||
per_page?: string;
|
per_page?: string;
|
||||||
sort_by?: string;
|
sort_by?: string;
|
||||||
sort_order?: 'asc' | 'desc';
|
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 [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 [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
const [deleteName, setDeleteName] = useState<string>('');
|
const [deleteName, setDeleteName] = useState<string>('');
|
||||||
const [modelOpen, setModelOpen] = useState(false);
|
const [modelOpen, setModelOpen] = useState(false);
|
||||||
@@ -88,11 +95,46 @@ export default function UserIndex({ users, filters }: Props) {
|
|||||||
setPerPage(value);
|
setPerPage(value);
|
||||||
router.get(
|
router.get(
|
||||||
route('users.index'),
|
route('users.index'),
|
||||||
{ ...filters, per_page: value },
|
{ ...filters, per_page: value, search: searchTerm, role: roleFilter },
|
||||||
{ preserveState: false, replace: true, preserveScroll: true }
|
{ 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) => {
|
const handleSort = (field: string) => {
|
||||||
let newSortBy: string | undefined = field;
|
let newSortBy: string | undefined = field;
|
||||||
let newSortOrder: 'asc' | 'desc' | undefined = 'asc';
|
let newSortOrder: 'asc' | 'desc' | undefined = 'asc';
|
||||||
@@ -145,14 +187,54 @@ export default function UserIndex({ users, filters }: Props) {
|
|||||||
管理系統使用者帳號與角色分配
|
管理系統使用者帳號與角色分配
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Can permission="users.create">
|
</div>
|
||||||
<Link href={route('users.create')}>
|
|
||||||
<Button className="button-filled-primary">
|
{/* Toolbar */}
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
|
||||||
新增使用者
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
</Button>
|
{/* Search */}
|
||||||
</Link>
|
<div className="flex-1 relative">
|
||||||
</Can>
|
<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')} 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>
|
||||||
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
|
|||||||
const [dateStart, setDateStart] = useState(filters.date_start || "");
|
const [dateStart, setDateStart] = useState(filters.date_start || "");
|
||||||
const [dateEnd, setDateEnd] = useState(filters.date_end || "");
|
const [dateEnd, setDateEnd] = useState(filters.date_end || "");
|
||||||
const [dateRangeType, setDateRangeType] = useState("custom");
|
const [dateRangeType, setDateRangeType] = useState("custom");
|
||||||
|
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
|
||||||
|
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
@@ -108,18 +109,36 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
|
|||||||
date_end: dateEnd,
|
date_end: dateEnd,
|
||||||
sort_field: sortField,
|
sort_field: sortField,
|
||||||
sort_direction: sortDirection,
|
sort_direction: sortDirection,
|
||||||
|
per_page: perPage,
|
||||||
},
|
},
|
||||||
{ preserveState: true }
|
{ 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 = () => {
|
const handleClearFilters = () => {
|
||||||
setSearchTerm("");
|
setSearchTerm("");
|
||||||
setCategoryFilter("all");
|
setCategoryFilter("all");
|
||||||
setDateStart("");
|
setDateStart("");
|
||||||
setDateEnd("");
|
setDateEnd("");
|
||||||
setDateRangeType("custom");
|
setDateRangeType("custom");
|
||||||
router.get(route("utility-fees.index"));
|
router.get(route("utility-fees.index"), { per_page: perPage }, { preserveState: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSort = (field: string) => {
|
const handleSort = (field: string) => {
|
||||||
@@ -147,6 +166,7 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
|
|||||||
date_end: dateEnd,
|
date_end: dateEnd,
|
||||||
sort_field: newField,
|
sort_field: newField,
|
||||||
sort_direction: newDirection,
|
sort_direction: newDirection,
|
||||||
|
per_page: perPage,
|
||||||
},
|
},
|
||||||
{ preserveState: true }
|
{ preserveState: true }
|
||||||
);
|
);
|
||||||
@@ -469,7 +489,26 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</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} />
|
<Pagination links={fees.links} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user