feat(frontend): persist page filters in query params

This commit is contained in:
2026-04-29 11:31:12 +03:30
parent 06c05ba8e9
commit 06d083c818
12 changed files with 680 additions and 345 deletions

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from "react"
import { useSearchParams } from "react-router-dom"
import { Plus, Building2, Pencil, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { useWorkspace } from "../context/WorkspaceContext"
@@ -10,106 +11,113 @@ import {
canDeleteWorkspaceResource,
canWorkspace,
} from "../lib/permissions"
import { type Client } from "../types/client"
import { getClients } from "../api/clients"
import CreateClientModal from "../components/CreateClientModal"
import EditClientModal from "../components/EditClientModal"
import { type Client } from "../types/client"
import { getClients } from "../api/clients"
import CreateClientModal from "../components/CreateClientModal"
import EditClientModal from "../components/EditClientModal"
import DeleteClientModal from "../components/DeleteClientModal"
import FilterBar from "../components/FilterBar"
import { ListPageSkeleton } from "../components/ListPageSkeleton"
import { Button } from "../components/ui/button"
import { Card, CardContent, CardTitle } from "../components/ui/card"
import { Pagination } from "../components/Pagination"
import { readNumberParam, readStringParam, updateQueryParams } from "../lib/queryParams"
export default function Clients() {
const { activeWorkspace } = useWorkspace()
const { user } = useAppContext()
const [clients, setClients] = useState<Client[]>([])
const [isLoading, setIsLoading] = useState(true)
// Pagination States
const [currentPage, setCurrentPage] = useState(1)
const [totalItems, setTotalItems] = useState(0)
const [limit, setLimit] = useState(10)
// Filter States
const [searchQuery, setSearchQuery] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [ordering, setOrdering] = useState("-created_at")
// Modal States
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [editClient, setEditClient] = useState<Client | null>(null)
const [deleteClient, setDeleteClient] = useState<Client | null>(null)
const { t, lang } = useTranslation()
const isFa = lang === "fa"
const [isLoading, setIsLoading] = useState(true)
const [searchParams, setSearchParams] = useSearchParams()
const [totalItems, setTotalItems] = useState(0)
const [debouncedSearch, setDebouncedSearch] = useState("")
const searchQuery = readStringParam(searchParams, "search", "")
const ordering = readStringParam(searchParams, "ordering", "-created_at")
const currentPage = Math.max(1, readNumberParam(searchParams, "page", 1))
const limit = Math.max(1, readNumberParam(searchParams, "limit", 10))
// Modal States
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [editClient, setEditClient] = useState<Client | null>(null)
const [deleteClient, setDeleteClient] = useState<Client | null>(null)
const { t, lang } = useTranslation()
const isFa = lang === "fa"
const workspaceRole = activeWorkspace?.my_role
const canCreateClient = canWorkspace(workspaceRole, CLIENTS_CREATE)
const canEditClient = canWorkspace(workspaceRole, CLIENTS_EDIT)
const orderingOptions = [
{ value: "-created_at", label: t.ordering?.createdAtDesc || "Newest First" },
{ value: "created_at", label: t.ordering?.createdAt || "Oldest First" },
{ value: "name", label: t.ordering?.name || "Name (A-Z)" },
{ value: "-name", label: t.ordering?.nameDesc || "Name (Z-A)" },
{ value: "-updated_at", label: t.ordering?.updatedAtDesc || "Recently Updated" },
]
useEffect(() => {
setCurrentPage(1)
}, [debouncedSearch, ordering])
// Debounce search input to avoid spamming the API
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearch(searchQuery)
}, 500)
return () => clearTimeout(handler)
}, [searchQuery])
const fetchClientsList = async () => {
if (!activeWorkspace?.id) {
setIsLoading(false)
return
}
setIsLoading(true)
try {
const offset = (currentPage - 1) * limit
const data: any = await getClients(activeWorkspace.id, debouncedSearch, ordering, limit, offset)
const items = data?.results || (Array.isArray(data) ? data : [])
const count = data?.count !== undefined ? data.count : items.length
setClients(items)
setTotalItems(count)
const orderingOptions = [
{ value: "-created_at", label: t.ordering?.createdAtDesc || "Newest First" },
{ value: "created_at", label: t.ordering?.createdAt || "Oldest First" },
{ value: "name", label: t.ordering?.name || "Name (A-Z)" },
{ value: "-name", label: t.ordering?.nameDesc || "Name (Z-A)" },
{ value: "-updated_at", label: t.ordering?.updatedAtDesc || "Recently Updated" },
]
// Debounce search input to avoid spamming the API
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearch(searchQuery)
}, 500)
return () => clearTimeout(handler)
}, [searchQuery])
const fetchClientsList = async () => {
if (!activeWorkspace?.id) {
setIsLoading(false)
return
}
setIsLoading(true)
try {
const offset = (currentPage - 1) * limit
const data: any = await getClients(activeWorkspace.id, debouncedSearch, ordering, limit, offset)
const items = data?.results || (Array.isArray(data) ? data : [])
const count = data?.count !== undefined ? data.count : items.length
setClients(items)
setTotalItems(count)
} catch (error) {
console.error(t.clients.errors.fetchFailed, error)
toast.error(t.clients.errors.fetchFailed)
setClients([])
} finally {
setIsLoading(false)
}
}
const formatDate = (dateStr: string | undefined) => {
if (!dateStr) return "-"
try {
const date = new Date(dateStr)
return new Intl.DateTimeFormat(isFa ? "fa-IR" : "en-US", {
dateStyle: "long",
timeZone: "Asia/Tehran"
}).format(date)
} catch (e) {
return dateStr
}
}
useEffect(() => {
fetchClientsList()
}, [activeWorkspace?.id, debouncedSearch, ordering, currentPage, limit])
}
}
const formatDate = (dateStr: string | undefined) => {
if (!dateStr) return "-"
try {
const date = new Date(dateStr)
return new Intl.DateTimeFormat(isFa ? "fa-IR" : "en-US", {
dateStyle: "long",
timeZone: "Asia/Tehran"
}).format(date)
} catch (e) {
return dateStr
}
}
useEffect(() => {
fetchClientsList()
}, [activeWorkspace?.id, debouncedSearch, ordering, currentPage, limit])
const updateListParams = (updates: Record<string, string | number | null | undefined>) => {
setSearchParams(
(current) =>
updateQueryParams(current, updates, {
search: "",
ordering: "-created_at",
page: 1,
limit: 10,
}),
{ replace: true },
)
}
if (!activeWorkspace) {
return (
<div className="mx-auto max-w-7xl p-4 md:p-6">
@@ -148,9 +156,9 @@ export default function Clients() {
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
<FilterBar
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
setSearchQuery={(value) => updateListParams({ search: value, page: 1 })}
ordering={ordering}
setOrdering={setOrdering}
setOrdering={(value) => updateListParams({ ordering: value, page: 1 })}
orderingOptions={orderingOptions}
searchPlaceholder={t.clients.searchPlaceholder}
/>
@@ -161,7 +169,7 @@ export default function Clients() {
) : (
<div className="flex flex-1 flex-col gap-6">
{clients.length === 0 ? (
<div className="flex flex-1 rounded-3xl border-2 border-dashed border-slate-200 bg-white p-12 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="flex flex-col flex-1 rounded-3xl border-2 border-dashed border-slate-200 bg-white p-12 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900">
<Building2 className="mx-auto mb-3 h-12 w-12 text-slate-300 dark:text-slate-700" />
<h3 className="text-lg font-medium text-slate-900 dark:text-white">{t.clients.noClients}</h3>
<p className="mt-1 text-slate-500 dark:text-slate-400">
@@ -238,8 +246,8 @@ export default function Clients() {
currentPage={currentPage}
totalCount={totalItems}
limit={limit}
onPageChange={setCurrentPage}
onLimitChange={setLimit}
onPageChange={(page) => updateListParams({ page })}
onLimitChange={(pageLimit) => updateListParams({ limit: pageLimit, page: 1 })}
/>
)}
</div>
@@ -248,30 +256,30 @@ export default function Clients() {
{canCreateClient && (
<CreateClientModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={fetchClientsList}
workspaceId={activeWorkspace.id}
/>
)}
{canEditClient && (
<EditClientModal
isOpen={!!editClient}
onClose={() => setEditClient(null)}
onSuccess={fetchClientsList}
client={editClient}
/>
)}
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={fetchClientsList}
workspaceId={activeWorkspace.id}
/>
)}
{canEditClient && (
<EditClientModal
isOpen={!!editClient}
onClose={() => setEditClient(null)}
onSuccess={fetchClientsList}
client={editClient}
/>
)}
{!!deleteClient && (
<DeleteClientModal
isOpen={!!deleteClient}
onClose={() => setDeleteClient(null)}
onSuccess={fetchClientsList}
client={deleteClient}
/>
)}
</div>
)
}
/>
)}
</div>
)
}