feat: 實作使用者管理與公共事業費分頁標準化
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 50s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped

This commit is contained in:
2026-01-20 15:53:15 +08:00
parent 74728c47b9
commit 89183ca124
6 changed files with 207 additions and 31 deletions

View File

@@ -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']),
]);
}

View File

@@ -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']),
]);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>