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,6 +1,5 @@
import { Search, ArrowUpDown } from 'lucide-react'; import { Search, ArrowUpDown } from 'lucide-react';
import { Select } from './ui/Select'; import { Select } from './ui/Select';
import { Input } from './ui/input';
interface FilterBarProps { interface FilterBarProps {
searchQuery: string; searchQuery: string;
@@ -19,7 +18,6 @@ export default function FilterBar({
orderingOptions, orderingOptions,
searchPlaceholder searchPlaceholder
}: FilterBarProps) { }: FilterBarProps) {
return ( return (
<div className="flex flex-col sm:flex-row gap-4 mb-6"> <div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="relative flex-1"> <div className="relative flex-1">

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import { useTranslation } from "../hooks/useTranslation";
interface InfiniteScrollProps { interface InfiniteScrollProps {
children: React.ReactNode; children: React.ReactNode;
@@ -17,6 +18,7 @@ export const InfiniteScroll: React.FC<InfiniteScrollProps> = ({
className = "", className = "",
loader, loader,
}) => { }) => {
const { t } = useTranslation();
const observerTarget = useRef<HTMLDivElement>(null); const observerTarget = useRef<HTMLDivElement>(null);
const onLoadMoreRef = useRef(onLoadMore); const onLoadMoreRef = useRef(onLoadMore);
const hasMoreRef = useRef(hasMore); const hasMoreRef = useRef(hasMore);
@@ -57,7 +59,7 @@ export const InfiniteScroll: React.FC<InfiniteScrollProps> = ({
{isLoading && ( {isLoading && (
loader || ( loader || (
<div className="py-2 text-center text-xs text-slate-500 dark:text-slate-400"> <div className="py-2 text-center text-xs text-slate-500 dark:text-slate-400">
Loading... {t.loading || "Loading..."}
</div> </div>
) )
)} )}

View File

@@ -27,8 +27,8 @@ export function SearchableSelect({
onChange, onChange,
options, options,
placeholder = "", placeholder = "",
searchPlaceholder = "Search...", searchPlaceholder,
emptyLabel = "No results", emptyLabel,
disabled = false, disabled = false,
className = "", className = "",
buttonClassName = "", buttonClassName = "",
@@ -111,7 +111,7 @@ export function SearchableSelect({
<Input <Input
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder={searchPlaceholder} placeholder={searchPlaceholder || "Search..."}
className="h-9 pl-9" className="h-9 pl-9"
autoFocus autoFocus
/> />
@@ -138,7 +138,9 @@ export function SearchableSelect({
</button> </button>
))} ))}
{filteredOptions.length === 0 && ( {filteredOptions.length === 0 && (
<div className="px-3 py-3 text-sm text-slate-500 dark:text-slate-400">{emptyLabel}</div> <div className="px-3 py-3 text-sm text-slate-500 dark:text-slate-400">
{emptyLabel || "No results"}
</div>
)} )}
</div> </div>
</div>, </div>,

90
src/lib/queryParams.ts Normal file
View File

@@ -0,0 +1,90 @@
export type QueryParamUpdateValue =
| string
| number
| boolean
| null
| undefined
| Array<string | number>;
type QueryParamDefaults = Record<string, string | number | boolean | undefined>;
const normalizeScalar = (value: string | number | boolean) => {
if (typeof value === "boolean") {
return value ? "1" : "0";
}
return String(value);
};
export const readStringParam = (
searchParams: URLSearchParams,
key: string,
fallback = "",
) => searchParams.get(key) ?? fallback;
export const readNumberParam = (
searchParams: URLSearchParams,
key: string,
fallback: number,
) => {
const rawValue = searchParams.get(key);
if (!rawValue) return fallback;
const parsedValue = Number(rawValue);
if (!Number.isFinite(parsedValue)) return fallback;
return parsedValue;
};
export const readBooleanParam = (
searchParams: URLSearchParams,
key: string,
fallback = false,
) => {
const rawValue = searchParams.get(key);
if (rawValue === null) return fallback;
return rawValue === "1" || rawValue === "true";
};
export const readArrayParam = (searchParams: URLSearchParams, key: string) =>
searchParams.getAll(key).filter(Boolean);
export const updateQueryParams = (
currentParams: URLSearchParams,
updates: Record<string, QueryParamUpdateValue>,
defaults: QueryParamDefaults = {},
) => {
const nextParams = new URLSearchParams(currentParams);
Object.entries(updates).forEach(([key, value]) => {
nextParams.delete(key);
if (Array.isArray(value)) {
const normalizedValues = value
.map((item) => String(item).trim())
.filter(Boolean);
normalizedValues.forEach((item) => nextParams.append(key, item));
return;
}
if (value === null || value === undefined) return;
const normalizedValue =
typeof value === "string" ? value.trim() : normalizeScalar(value);
const defaultValue = defaults[key];
if (!normalizedValue.length) return;
if (
defaultValue !== undefined &&
normalizedValue === normalizeScalar(defaultValue)
) {
return;
}
nextParams.set(key, normalizedValue);
});
return nextParams;
};

54
src/lib/reportFilters.ts Normal file
View File

@@ -0,0 +1,54 @@
import type { ReportPeriod } from "../api/reports";
import type { ReportsFilterDraft } from "../components/reports/ReportsFilterBar";
import { readArrayParam, readStringParam, updateQueryParams } from "./queryParams";
export const DEFAULT_REPORTS_FILTERS: ReportsFilterDraft = {
period: "this_month",
from_date: "",
to_date: "",
user: "",
client: "",
project: "",
tags: [],
};
export const readReportsFiltersFromParams = (
searchParams: URLSearchParams,
): ReportsFilterDraft => ({
period: readStringParam(
searchParams,
"period",
DEFAULT_REPORTS_FILTERS.period,
) as ReportPeriod,
from_date: readStringParam(searchParams, "from", ""),
to_date: readStringParam(searchParams, "to", ""),
user: readStringParam(searchParams, "user", ""),
client: readStringParam(searchParams, "client", ""),
project: readStringParam(searchParams, "project", ""),
tags: readArrayParam(searchParams, "tags"),
});
export const writeReportsFiltersToParams = (
currentParams: URLSearchParams,
filters: ReportsFilterDraft,
) =>
updateQueryParams(
currentParams,
{
period: filters.period,
from: filters.from_date,
to: filters.to_date,
user: filters.user,
client: filters.client,
project: filters.project,
tags: filters.tags,
},
{
period: DEFAULT_REPORTS_FILTERS.period,
from: "",
to: "",
user: "",
client: "",
project: "",
},
);

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useSearchParams } from "react-router-dom"
import { Plus, Building2, Pencil, Trash2 } from "lucide-react" import { Plus, Building2, Pencil, Trash2 } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { useWorkspace } from "../context/WorkspaceContext" import { useWorkspace } from "../context/WorkspaceContext"
@@ -20,22 +21,20 @@ import { ListPageSkeleton } from "../components/ListPageSkeleton"
import { Button } from "../components/ui/button" import { Button } from "../components/ui/button"
import { Card, CardContent, CardTitle } from "../components/ui/card" import { Card, CardContent, CardTitle } from "../components/ui/card"
import { Pagination } from "../components/Pagination" import { Pagination } from "../components/Pagination"
import { readNumberParam, readStringParam, updateQueryParams } from "../lib/queryParams"
export default function Clients() { export default function Clients() {
const { activeWorkspace } = useWorkspace() const { activeWorkspace } = useWorkspace()
const { user } = useAppContext() const { user } = useAppContext()
const [clients, setClients] = useState<Client[]>([]) const [clients, setClients] = useState<Client[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [searchParams, setSearchParams] = useSearchParams()
// Pagination States
const [currentPage, setCurrentPage] = useState(1)
const [totalItems, setTotalItems] = useState(0) const [totalItems, setTotalItems] = useState(0)
const [limit, setLimit] = useState(10)
// Filter States
const [searchQuery, setSearchQuery] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [ordering, setOrdering] = useState("-created_at") 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 // Modal States
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
@@ -56,10 +55,6 @@ export default function Clients() {
{ value: "-updated_at", label: t.ordering?.updatedAtDesc || "Recently Updated" }, { value: "-updated_at", label: t.ordering?.updatedAtDesc || "Recently Updated" },
] ]
useEffect(() => {
setCurrentPage(1)
}, [debouncedSearch, ordering])
// Debounce search input to avoid spamming the API // Debounce search input to avoid spamming the API
useEffect(() => { useEffect(() => {
const handler = setTimeout(() => { const handler = setTimeout(() => {
@@ -110,6 +105,19 @@ export default function Clients() {
fetchClientsList() fetchClientsList()
}, [activeWorkspace?.id, debouncedSearch, ordering, currentPage, limit]) }, [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) { if (!activeWorkspace) {
return ( return (
<div className="mx-auto max-w-7xl p-4 md:p-6"> <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"> <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 <FilterBar
searchQuery={searchQuery} searchQuery={searchQuery}
setSearchQuery={setSearchQuery} setSearchQuery={(value) => updateListParams({ search: value, page: 1 })}
ordering={ordering} ordering={ordering}
setOrdering={setOrdering} setOrdering={(value) => updateListParams({ ordering: value, page: 1 })}
orderingOptions={orderingOptions} orderingOptions={orderingOptions}
searchPlaceholder={t.clients.searchPlaceholder} searchPlaceholder={t.clients.searchPlaceholder}
/> />
@@ -161,7 +169,7 @@ export default function Clients() {
) : ( ) : (
<div className="flex flex-1 flex-col gap-6"> <div className="flex flex-1 flex-col gap-6">
{clients.length === 0 ? ( {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" /> <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> <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"> <p className="mt-1 text-slate-500 dark:text-slate-400">
@@ -238,8 +246,8 @@ export default function Clients() {
currentPage={currentPage} currentPage={currentPage}
totalCount={totalItems} totalCount={totalItems}
limit={limit} limit={limit}
onPageChange={setCurrentPage} onPageChange={(page) => updateListParams({ page })}
onLimitChange={setLimit} onLimitChange={(pageLimit) => updateListParams({ limit: pageLimit, page: 1 })}
/> />
)} )}
</div> </div>

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { History, ShieldCheck, SlidersHorizontal } from "lucide-react"; import { History, ShieldCheck, SlidersHorizontal } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -14,6 +15,7 @@ import { LogsFeed } from "../components/logs/LogsFeed";
import { LogsFilterBar, type LogsFilterDraft } from "../components/logs/LogsFilterBar"; import { LogsFilterBar, type LogsFilterDraft } from "../components/logs/LogsFilterBar";
import { useWorkspace } from "../context/WorkspaceContext"; import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation"; import { useTranslation } from "../hooks/useTranslation";
import { readStringParam, updateQueryParams } from "../lib/queryParams";
import { canWorkspace, WORKSPACE_LOGS_VIEW } from "../lib/permissions"; import { canWorkspace, WORKSPACE_LOGS_VIEW } from "../lib/permissions";
const DEFAULT_FILTERS: LogsFilterDraft = { const DEFAULT_FILTERS: LogsFilterDraft = {
@@ -26,12 +28,22 @@ const DEFAULT_FILTERS: LogsFilterDraft = {
ordering: "-timestamp", ordering: "-timestamp",
}; };
const DEFAULT_QUERY_FILTERS: Record<string, string> = {
search: DEFAULT_FILTERS.search,
section: DEFAULT_FILTERS.section,
event: DEFAULT_FILTERS.event,
actor: DEFAULT_FILTERS.actor,
from: DEFAULT_FILTERS.from,
to: DEFAULT_FILTERS.to,
ordering: DEFAULT_FILTERS.ordering,
};
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
export default function Logs() { export default function Logs() {
const { t, lang } = useTranslation(); const { t, lang } = useTranslation();
const { activeWorkspace } = useWorkspace(); const { activeWorkspace } = useWorkspace();
const [filters, setFilters] = useState<LogsFilterDraft>(DEFAULT_FILTERS); const [searchParams, setSearchParams] = useSearchParams();
const [memberships, setMemberships] = useState<WorkspaceMembership[]>([]); const [memberships, setMemberships] = useState<WorkspaceMembership[]>([]);
const [logs, setLogs] = useState<WorkspaceLogItem[]>([]); const [logs, setLogs] = useState<WorkspaceLogItem[]>([]);
const [totalLogs, setTotalLogs] = useState(0); const [totalLogs, setTotalLogs] = useState(0);
@@ -45,9 +57,20 @@ export default function Logs() {
const workspaceRole = activeWorkspace?.my_role; const workspaceRole = activeWorkspace?.my_role;
const canViewLogs = canWorkspace(workspaceRole, WORKSPACE_LOGS_VIEW); const canViewLogs = canWorkspace(workspaceRole, WORKSPACE_LOGS_VIEW);
const isWorkspaceRoleResolved = Boolean(workspaceRole); const isWorkspaceRoleResolved = Boolean(workspaceRole);
const filters = useMemo<LogsFilterDraft>(
() => ({
search: readStringParam(searchParams, "search", DEFAULT_FILTERS.search),
section: readStringParam(searchParams, "section", DEFAULT_FILTERS.section) as LogsFilterDraft["section"],
event: readStringParam(searchParams, "event", DEFAULT_FILTERS.event) as LogsFilterDraft["event"],
actor: readStringParam(searchParams, "actor", DEFAULT_FILTERS.actor),
from: readStringParam(searchParams, "from", DEFAULT_FILTERS.from),
to: readStringParam(searchParams, "to", DEFAULT_FILTERS.to),
ordering: readStringParam(searchParams, "ordering", DEFAULT_FILTERS.ordering) as LogsFilterDraft["ordering"],
}),
[searchParams],
);
useEffect(() => { useEffect(() => {
setFilters(DEFAULT_FILTERS);
setLogs([]); setLogs([]);
setTotalLogs(0); setTotalLogs(0);
setSelectedLogId(null); setSelectedLogId(null);
@@ -284,7 +307,25 @@ export default function Logs() {
users={memberships} users={memberships}
isLoadingUsers={isLoadingUsers} isLoadingUsers={isLoadingUsers}
canSelectUsers={canViewLogs} canSelectUsers={canViewLogs}
onApply={setFilters} onApply={(nextFilters) =>
setSearchParams(
(current) =>
updateQueryParams(
current,
{
search: nextFilters.search,
section: nextFilters.section,
event: nextFilters.event,
actor: nextFilters.actor,
from: nextFilters.from,
to: nextFilters.to,
ordering: nextFilters.ordering,
},
DEFAULT_QUERY_FILTERS,
),
{ replace: true },
)
}
/> />
<LogsFeed <LogsFeed

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "../hooks/useTranslation"; import { useTranslation } from "../hooks/useTranslation";
import { getProjects, deleteProject, type Project } from "../api/projects"; import { getProjects, deleteProject, type Project } from "../api/projects";
import { getClients } from "../api/clients"; import { getClients } from "../api/clients";
@@ -23,6 +24,13 @@ import {
canDeleteWorkspaceResource, canDeleteWorkspaceResource,
canWorkspace, canWorkspace,
} from "../lib/permissions"; } from "../lib/permissions";
import {
readArrayParam,
readBooleanParam,
readNumberParam,
readStringParam,
updateQueryParams,
} from "../lib/queryParams";
export const Projects: React.FC = () => { export const Projects: React.FC = () => {
const { t, lang } = useTranslation(); const { t, lang } = useTranslation();
@@ -39,12 +47,32 @@ export const Projects: React.FC = () => {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null); const [editingProject, setEditingProject] = useState<Project | null>(null);
const [search, setSearch] = useState(""); const [searchParams, setSearchParams] = useSearchParams();
const [ordering, setOrdering] = useState("-created_at"); const search = useMemo(() => readStringParam(searchParams, "search", ""), [searchParams]);
const [isArchived, setIsArchived] = useState(false); const ordering = useMemo(
const [selectedClientIds, setSelectedClientIds] = useState<string[]>([]); () => readStringParam(searchParams, "ordering", "-created_at"),
const [currentPage, setCurrentPage] = useState(1); [searchParams],
const [limit, setLimit] = useState(10); );
const isArchived = useMemo(
() => readBooleanParam(searchParams, "archived", false),
[searchParams],
);
const selectedClientIds = useMemo(
() => readArrayParam(searchParams, "clients"),
[searchParams],
);
const selectedClientIdsKey = useMemo(
() => selectedClientIds.join(","),
[selectedClientIds],
);
const currentPage = useMemo(
() => Math.max(1, readNumberParam(searchParams, "page", 1)),
[searchParams],
);
const limit = useMemo(
() => Math.max(1, readNumberParam(searchParams, "limit", 10)),
[searchParams],
);
const [totalItems, setTotalItems] = useState(0); const [totalItems, setTotalItems] = useState(0);
const [deleteModal, setDeleteModal] = useState<{isOpen: boolean; project: Project | null}>({isOpen: false, project: null}); const [deleteModal, setDeleteModal] = useState<{isOpen: boolean; project: Project | null}>({isOpen: false, project: null});
@@ -57,10 +85,6 @@ export const Projects: React.FC = () => {
{ value: '-name', label: t.ordering?.nameDesc || 'Name (Z-A)' }, { value: '-name', label: t.ordering?.nameDesc || 'Name (Z-A)' },
]; ];
useEffect(() => {
setCurrentPage(1);
}, [search, ordering, isArchived, selectedClientIds]);
const fetchProjectList = async () => { const fetchProjectList = async () => {
if (!activeWorkspace) return; if (!activeWorkspace) return;
setLoading(true); setLoading(true);
@@ -106,7 +130,7 @@ export const Projects: React.FC = () => {
void fetchProjectList(); void fetchProjectList();
}, 300); }, 300);
return () => clearTimeout(delayDebounceFn); return () => clearTimeout(delayDebounceFn);
}, [activeWorkspace, currentPage, limit, search, isArchived, ordering, selectedClientIds]); }, [activeWorkspace?.id, currentPage, limit, search, isArchived, ordering, selectedClientIdsKey]);
useEffect(() => { useEffect(() => {
const handleCreated = () => void fetchProjectList(); const handleCreated = () => void fetchProjectList();
@@ -119,7 +143,7 @@ export const Projects: React.FC = () => {
window.removeEventListener("project_created", handleCreated); window.removeEventListener("project_created", handleCreated);
window.removeEventListener("project_updated", handleUpdated); window.removeEventListener("project_updated", handleUpdated);
}; };
}, [activeWorkspace, currentPage, limit, search, isArchived, ordering]); }, [activeWorkspace?.id, currentPage, limit, search, isArchived, ordering, selectedClientIdsKey]);
const confirmDelete = async () => { const confirmDelete = async () => {
if (!deleteModal.project) return; if (!deleteModal.project) return;
@@ -159,14 +183,29 @@ export const Projects: React.FC = () => {
const selected = clients.filter((client) => selectedClientIds.includes(client.id)); const selected = clients.filter((client) => selectedClientIds.includes(client.id));
const unselected = clients.filter((client) => !selectedClientIds.includes(client.id)); const unselected = clients.filter((client) => !selectedClientIds.includes(client.id));
return [...selected, ...unselected]; return [...selected, ...unselected];
}, [clients, selectedClientIds]); }, [clients, selectedClientIdsKey]);
const toggleClientFilter = (clientId: string) => { const toggleClientFilter = (clientId: string) => {
setCurrentPage(1); const nextClientIds = selectedClientIds.includes(clientId)
setSelectedClientIds((current) => ? selectedClientIds.filter((id) => id !== clientId)
current.includes(clientId) : [...selectedClientIds, clientId];
? current.filter((id) => id !== clientId)
: [...current, clientId], updateListParams({ clients: nextClientIds, page: 1 });
};
const updateListParams = (
updates: Record<string, string | number | boolean | null | undefined | string[]>,
) => {
setSearchParams(
(current) =>
updateQueryParams(current, updates, {
search: "",
ordering: "-created_at",
archived: false,
page: 1,
limit: 10,
}),
{ replace: true },
); );
}; };
@@ -194,7 +233,7 @@ export const Projects: React.FC = () => {
{canArchiveProject && ( {canArchiveProject && (
<Button <Button
variant={isArchived ? "default" : "secondary"} variant={isArchived ? "default" : "secondary"}
onClick={() => setIsArchived(!isArchived)} onClick={() => updateListParams({ archived: !isArchived, page: 1 })}
className="flex-1 gap-2 shadow-sm sm:flex-none" className="flex-1 gap-2 shadow-sm sm:flex-none"
> >
<Archive className="h-4 w-4" /> <Archive className="h-4 w-4" />
@@ -218,9 +257,9 @@ export const Projects: React.FC = () => {
<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"> <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 <FilterBar
searchQuery={search} searchQuery={search}
setSearchQuery={setSearch} setSearchQuery={(value) => updateListParams({ search: value, page: 1 })}
ordering={ordering} ordering={ordering}
setOrdering={setOrdering} setOrdering={(value) => updateListParams({ ordering: value, page: 1 })}
orderingOptions={orderingOptions} orderingOptions={orderingOptions}
searchPlaceholder={t.projects?.searchPlaceholder || 'Search projects...'} searchPlaceholder={t.projects?.searchPlaceholder || 'Search projects...'}
/> />
@@ -233,8 +272,7 @@ export const Projects: React.FC = () => {
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setCurrentPage(1); updateListParams({ clients: [], page: 1 });
setSelectedClientIds([]);
}} }}
className="text-xs font-medium text-slate-500 transition hover:text-slate-900 dark:text-slate-400 dark:hover:text-white" className="text-xs font-medium text-slate-500 transition hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
> >
@@ -291,9 +329,10 @@ export const Projects: React.FC = () => {
) : ( ) : (
<div className="flex flex-1 flex-col gap-6"> <div className="flex flex-1 flex-col gap-6">
{projects.length === 0 ? ( {projects.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" /> <Building2 className="mx-auto mb-3 h-12 w-12 text-slate-300 dark:text-slate-700" />
<p className="font-medium text-slate-500 dark:text-slate-400">{t.projects?.emptyState || 'No projects found'}</p> <h3 className="text-lg font-medium text-slate-900 dark:text-white">{t.projects?.emptyState || 'No projects found'}</h3>
<p className="mt-1 text-slate-500 dark:text-slate-400">{t.projects?.noProjectsSearch}</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 2xl:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 2xl:grid-cols-3">
@@ -374,8 +413,8 @@ export const Projects: React.FC = () => {
currentPage={currentPage} currentPage={currentPage}
totalCount={totalItems} totalCount={totalItems}
limit={limit} limit={limit}
onPageChange={setCurrentPage} onPageChange={(page) => updateListParams({ page })}
onLimitChange={setLimit} onLimitChange={(pageLimit) => updateListParams({ limit: pageLimit, page: 1 })}
pageSizeOptions={[10, 20, 50]} pageSizeOptions={[10, 20, 50]}
/> />
</div> </div>

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { BarChart3, Table2 } from "lucide-react"; import { BarChart3, Table2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -21,6 +22,12 @@ import { ReportsFilterBar, type ReportsFilterDraft } from "../components/reports
import { ReportsTablePanel } from "../components/reports/ReportsTablePanel"; import { ReportsTablePanel } from "../components/reports/ReportsTablePanel";
import { useWorkspace } from "../context/WorkspaceContext"; import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation"; import { useTranslation } from "../hooks/useTranslation";
import {
DEFAULT_REPORTS_FILTERS,
readReportsFiltersFromParams,
writeReportsFiltersToParams,
} from "../lib/reportFilters";
import { readStringParam, updateQueryParams } from "../lib/queryParams";
import { canWorkspace, WORKSPACE_MEMBERS_VIEW } from "../lib/permissions"; import { canWorkspace, WORKSPACE_MEMBERS_VIEW } from "../lib/permissions";
type Tab = "chart" | "table"; type Tab = "chart" | "table";
@@ -86,7 +93,8 @@ const getCurrentLanguageAwareMonthRange = (lang: "en" | "fa") => {
export default function Reports() { export default function Reports() {
const { t, lang } = useTranslation(); const { t, lang } = useTranslation();
const { activeWorkspace } = useWorkspace(); const { activeWorkspace } = useWorkspace();
const [tab, setTab] = useState<Tab>("chart"); const [searchParams, setSearchParams] = useSearchParams();
const tab = (readStringParam(searchParams, "tab", "chart") as Tab);
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [clients, setClients] = useState<{ id: string; name: string }[]>([]); const [clients, setClients] = useState<{ id: string; name: string }[]>([]);
const [tags, setTags] = useState<Tag[]>([]); const [tags, setTags] = useState<Tag[]>([]);
@@ -105,16 +113,10 @@ export default function Reports() {
const canSelectUsers = canWorkspace(activeWorkspace?.my_role, WORKSPACE_MEMBERS_VIEW); const canSelectUsers = canWorkspace(activeWorkspace?.my_role, WORKSPACE_MEMBERS_VIEW);
const isWorkspaceRoleResolved = Boolean(activeWorkspace?.my_role); const isWorkspaceRoleResolved = Boolean(activeWorkspace?.my_role);
const showUserFilterLoading = !isWorkspaceRoleResolved || (canSelectUsers && isLoadingUsers); const showUserFilterLoading = !isWorkspaceRoleResolved || (canSelectUsers && isLoadingUsers);
const filters = useMemo<ReportsFilterDraft>(
const [filters, setFilters] = useState<ReportsFilterDraft>({ () => readReportsFiltersFromParams(searchParams),
period: "this_month", [searchParams],
from_date: "", );
to_date: "",
user: "",
client: "",
project: "",
tags: [],
});
useEffect(() => { useEffect(() => {
if (!activeWorkspace?.id) return; if (!activeWorkspace?.id) return;
@@ -177,6 +179,7 @@ export default function Reports() {
}; };
const apiFilters = useMemo<ReportFilters | null>(() => buildApiFilters(filters), [activeWorkspace?.id, canSelectUsers, filters, lang]); const apiFilters = useMemo<ReportFilters | null>(() => buildApiFilters(filters), [activeWorkspace?.id, canSelectUsers, filters, lang]);
const apiFiltersKey = apiFilters ? JSON.stringify(apiFilters) : "";
const runReportLoad = async (nextFilters: ReportFilters) => { const runReportLoad = async (nextFilters: ReportFilters) => {
setIsLoading(true); setIsLoading(true);
@@ -199,8 +202,7 @@ export default function Reports() {
useEffect(() => { useEffect(() => {
if (!apiFilters) return; if (!apiFilters) return;
void runReportLoad(apiFilters); void runReportLoad(apiFilters);
// eslint-disable-next-line react-hooks/exhaustive-deps }, [apiFilters, apiFiltersKey]);
}, [apiFilters?.workspace]);
const handleToggleDay = async (day: string) => { const handleToggleDay = async (day: string) => {
if (!apiFilters) return; if (!apiFilters) return;
@@ -283,7 +285,12 @@ export default function Reports() {
<div className="grid w-full grid-cols-2 rounded-2xl border border-slate-200 bg-slate-50 p-1 dark:border-slate-800 dark:bg-slate-950 lg:w-auto"> <div className="grid w-full grid-cols-2 rounded-2xl border border-slate-200 bg-slate-50 p-1 dark:border-slate-800 dark:bg-slate-950 lg:w-auto">
<button <button
type="button" type="button"
onClick={() => setTab("chart")} onClick={() =>
setSearchParams(
(current) => updateQueryParams(current, { tab: "chart" }, { tab: "chart" }),
{ replace: true },
)
}
className={`inline-flex h-11 items-center justify-center gap-2 rounded-xl px-4 text-sm font-medium transition ${ className={`inline-flex h-11 items-center justify-center gap-2 rounded-xl px-4 text-sm font-medium transition ${
tab === "chart" tab === "chart"
? "bg-white text-slate-900 shadow-sm dark:bg-slate-900 dark:text-white" ? "bg-white text-slate-900 shadow-sm dark:bg-slate-900 dark:text-white"
@@ -295,7 +302,12 @@ export default function Reports() {
</button> </button>
<button <button
type="button" type="button"
onClick={() => setTab("table")} onClick={() =>
setSearchParams(
(current) => updateQueryParams(current, { tab: "table" }, { tab: "chart" }),
{ replace: true },
)
}
className={`inline-flex h-11 items-center justify-center gap-2 rounded-xl px-4 text-sm font-medium transition ${ className={`inline-flex h-11 items-center justify-center gap-2 rounded-xl px-4 text-sm font-medium transition ${
tab === "table" tab === "table"
? "bg-white text-slate-900 shadow-sm dark:bg-slate-900 dark:text-white" ? "bg-white text-slate-900 shadow-sm dark:bg-slate-900 dark:text-white"
@@ -311,12 +323,12 @@ export default function Reports() {
<ReportsFilterBar <ReportsFilterBar
value={filters} value={filters}
onApply={(draft) => { onApply={(draft) =>
setFilters(draft); setSearchParams(
const nextFilters = buildApiFilters(draft); (current) => writeReportsFiltersToParams(current, draft),
if (!nextFilters) return; { replace: true },
void runReportLoad(nextFilters); )
}} }
projects={projects} projects={projects}
clients={clients} clients={clients}
tags={tags} tags={tags}

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Edit2, Plus, Tag as TagIcon, Trash2 } from "lucide-react"; import { Edit2, Plus, Tag as TagIcon, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -14,6 +15,7 @@ import { Pagination } from "../components/Pagination";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Card, CardContent, CardTitle } from "../components/ui/card"; import { Card, CardContent, CardTitle } from "../components/ui/card";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { readNumberParam, readStringParam, updateQueryParams } from "../lib/queryParams";
const DEFAULT_COLOR = "#3B82F6"; const DEFAULT_COLOR = "#3B82F6";
@@ -24,14 +26,15 @@ export default function Tags() {
const workspaceRole = activeWorkspace?.my_role; const workspaceRole = activeWorkspace?.my_role;
const canCreateTag = canWorkspace(workspaceRole, TAGS_CREATE); const canCreateTag = canWorkspace(workspaceRole, TAGS_CREATE);
const canEditTag = canWorkspace(workspaceRole, TAGS_EDIT); const canEditTag = canWorkspace(workspaceRole, TAGS_EDIT);
const [searchParams, setSearchParams] = useSearchParams();
const [tags, setTags] = useState<Tag[]>([]); const [tags, setTags] = useState<Tag[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [ordering, setOrdering] = useState("-updated_at");
const [currentPage, setCurrentPage] = useState(1);
const [totalItems, setTotalItems] = useState(0); const [totalItems, setTotalItems] = useState(0);
const [limit, setLimit] = useState(10); const searchQuery = readStringParam(searchParams, "search", "");
const ordering = readStringParam(searchParams, "ordering", "-updated_at");
const currentPage = Math.max(1, readNumberParam(searchParams, "page", 1));
const limit = Math.max(1, readNumberParam(searchParams, "limit", 10));
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [editingTag, setEditingTag] = useState<Tag | null>(null); const [editingTag, setEditingTag] = useState<Tag | null>(null);
@@ -48,10 +51,6 @@ export default function Tags() {
{ value: "-name", label: t.ordering?.nameDesc || "Name (Z-A)" }, { value: "-name", label: t.ordering?.nameDesc || "Name (Z-A)" },
]; ];
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, ordering]);
useEffect(() => { useEffect(() => {
if (!activeWorkspace?.id) return; if (!activeWorkspace?.id) return;
@@ -140,6 +139,19 @@ export default function Tags() {
} }
}; };
const updateListParams = (updates: Record<string, string | number | null | undefined>) => {
setSearchParams(
(current) =>
updateQueryParams(current, updates, {
search: "",
ordering: "-updated_at",
page: 1,
limit: 10,
}),
{ replace: true },
);
};
if (!activeWorkspace) { if (!activeWorkspace) {
return ( return (
<div className="mx-auto max-w-7xl p-4 md:p-6"> <div className="mx-auto max-w-7xl p-4 md:p-6">
@@ -172,9 +184,9 @@ export default function Tags() {
<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"> <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 <FilterBar
searchQuery={searchQuery} searchQuery={searchQuery}
setSearchQuery={setSearchQuery} setSearchQuery={(value) => updateListParams({ search: value, page: 1 })}
ordering={ordering} ordering={ordering}
setOrdering={setOrdering} setOrdering={(value) => updateListParams({ ordering: value, page: 1 })}
orderingOptions={orderingOptions} orderingOptions={orderingOptions}
searchPlaceholder={t.tags?.searchPlaceholder || "Search tags..."} searchPlaceholder={t.tags?.searchPlaceholder || "Search tags..."}
/> />
@@ -238,9 +250,12 @@ export default function Tags() {
})} })}
{tags.length === 0 && ( {tags.length === 0 && (
<div className="col-span-full flex flex-1 flex-col items-center justify-center rounded-3xl border-2 border-dashed border-slate-200 bg-white py-16 text-slate-500 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400"> <div className="col-span-full 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">
<TagIcon className="w-10 h-10 mb-3" /> <TagIcon className="mx-auto mb-3 h-12 w-12 text-slate-300 dark:text-slate-700" />
<p className="font-medium">{t.tags?.emptyState || "No tags found"}</p> <h3 className="text-lg font-medium text-slate-900 dark:text-white">{t.tags?.emptyState || "No tags found"}</h3>
<p className="mt-1 text-slate-500 dark:text-slate-400">
{searchQuery ? t.tags?.noTagsSearch : t.tags?.emptyState}
</p>
</div> </div>
)} )}
</div> </div>
@@ -249,8 +264,8 @@ export default function Tags() {
currentPage={currentPage} currentPage={currentPage}
totalCount={totalItems} totalCount={totalItems}
limit={limit} limit={limit}
onPageChange={setCurrentPage} onPageChange={(page) => updateListParams({ page })}
onLimitChange={setLimit} onLimitChange={(pageLimit) => updateListParams({ limit: pageLimit, page: 1 })}
/> />
</div> </div>
)} )}

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useSearchParams } from "react-router-dom";
import { CalendarDays, Check, ChevronDown, Clock3, DollarSign, MoreVertical, Pencil, Play, Plus, Search, Square, Tag as TagIcon, Trash2 } from "lucide-react"; import { CalendarDays, Check, ChevronDown, Clock3, DollarSign, MoreVertical, Pencil, Play, Plus, Search, Square, Tag as TagIcon, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -25,6 +26,11 @@ import { Input } from "../components/ui/input";
import { SearchableSelect } from "../components/ui/SearchableSelect"; import { SearchableSelect } from "../components/ui/SearchableSelect";
import { useWorkspace } from "../context/WorkspaceContext"; import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation"; import { useTranslation } from "../hooks/useTranslation";
import {
readArrayParam,
readStringParam,
updateQueryParams,
} from "../lib/queryParams";
type EntryModalMode = "manual" | "edit" | null; type EntryModalMode = "manual" | "edit" | null;
@@ -2011,6 +2017,7 @@ function TimesheetSkeleton({ loadingLabel }: { loadingLabel: string }) {
export default function Timesheet() { export default function Timesheet() {
const { t, lang } = useTranslation(); const { t, lang } = useTranslation();
const { activeWorkspace } = useWorkspace(); const { activeWorkspace } = useWorkspace();
const [searchParams, setSearchParams] = useSearchParams();
const isRtl = lang === "fa"; const isRtl = lang === "fa";
const extendedTimesheet = (t.timesheet as { const extendedTimesheet = (t.timesheet as {
deleteTitle?: string; deleteTitle?: string;
@@ -2047,9 +2054,18 @@ export default function Timesheet() {
const [activeRunningEntry, setActiveRunningEntry] = useState<TimeEntry | null>(null); const [activeRunningEntry, setActiveRunningEntry] = useState<TimeEntry | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(""); const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
const [filters, setFilters] = useState<TimeEntryFilters>(DEFAULT_ENTRY_FILTERS); const searchQuery = readStringParam(searchParams, "search", "");
const filters = useMemo<TimeEntryFilters>(
() => ({
projectId: readStringParam(searchParams, "project", DEFAULT_ENTRY_FILTERS.projectId),
clientId: readStringParam(searchParams, "client", DEFAULT_ENTRY_FILTERS.clientId),
tagIds: readArrayParam(searchParams, "tags"),
startedAfter: readStringParam(searchParams, "from", DEFAULT_ENTRY_FILTERS.startedAfter),
startedBefore: readStringParam(searchParams, "to", DEFAULT_ENTRY_FILTERS.startedBefore),
}),
[searchParams],
);
const [hasMoreHistory, setHasMoreHistory] = useState(false); const [hasMoreHistory, setHasMoreHistory] = useState(false);
const [nextOffset, setNextOffset] = useState<number | null>(0); const [nextOffset, setNextOffset] = useState<number | null>(0);
const [limit] = useState(20); const [limit] = useState(20);
@@ -2134,9 +2150,6 @@ export default function Timesheet() {
}, [activeWorkspace?.id, t.timesheet?.optionsError]); }, [activeWorkspace?.id, t.timesheet?.optionsError]);
useEffect(() => { useEffect(() => {
setSearchQuery("");
setDebouncedSearchQuery("");
setFilters(DEFAULT_ENTRY_FILTERS);
setGroupedHistory([]); setGroupedHistory([]);
setNextOffset(0); setNextOffset(0);
setHasMoreHistory(false); setHasMoreHistory(false);
@@ -2164,9 +2177,13 @@ export default function Timesheet() {
); );
if (!projectStillMatchesClient) { if (!projectStillMatchesClient) {
setFilters((current) => ({ ...current, projectId: "" })); setSearchParams(
(current) =>
updateQueryParams(current, { project: "" }, { project: "" }),
{ replace: true },
);
} }
}, [filters.clientId, filters.projectId, projects]); }, [filters.clientId, filters.projectId, projects, setSearchParams]);
const loadHistory = useCallback(async ({ offset = 0, append = false }: { offset?: number; append?: boolean } = {}) => { const loadHistory = useCallback(async ({ offset = 0, append = false }: { offset?: number; append?: boolean } = {}) => {
if (!activeWorkspace?.id) return; if (!activeWorkspace?.id) return;
@@ -2523,14 +2540,53 @@ export default function Timesheet() {
}, []); }, []);
const handleApplyFilters = useCallback((nextFilters: TimeEntryFilters) => { const handleApplyFilters = useCallback((nextFilters: TimeEntryFilters) => {
setFilters(nextFilters); setSearchParams(
}, []); (current) =>
updateQueryParams(
current,
{
project: nextFilters.projectId,
client: nextFilters.clientId,
tags: nextFilters.tagIds,
from: nextFilters.startedAfter,
to: nextFilters.startedBefore,
},
{
project: "",
client: "",
from: "",
to: "",
},
),
{ replace: true },
);
}, [setSearchParams]);
const handleClearFilters = useCallback(() => { const handleClearFilters = useCallback(() => {
setSearchQuery(""); setSearchParams(
(current) =>
updateQueryParams(
current,
{
search: "",
project: "",
client: "",
tags: [],
from: "",
to: "",
},
{
search: "",
project: "",
client: "",
from: "",
to: "",
},
),
{ replace: true },
);
setDebouncedSearchQuery(""); setDebouncedSearchQuery("");
setFilters(DEFAULT_ENTRY_FILTERS); }, [setSearchParams]);
}, []);
const handleLoadMore = useCallback(() => { const handleLoadMore = useCallback(() => {
if (!hasMoreHistory || nextOffset === null || isLoadingMore) return; if (!hasMoreHistory || nextOffset === null || isLoadingMore) return;
@@ -2789,7 +2845,13 @@ export default function Timesheet() {
<TimesheetFilterBar <TimesheetFilterBar
searchQuery={searchQuery} searchQuery={searchQuery}
filters={filters} filters={filters}
onSearchChange={setSearchQuery} onSearchChange={(value) =>
setSearchParams(
(current) =>
updateQueryParams(current, { search: value }, { search: "" }),
{ replace: true },
)
}
onApply={handleApplyFilters} onApply={handleApplyFilters}
onClearFilters={handleClearFilters} onClearFilters={handleClearFilters}
projects={projects} projects={projects}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { Plus, Trash2, Pencil, Eye } from 'lucide-react'; import { Plus, Trash2, Pencil, Eye, LayoutDashboard } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { fetchWorkspaces, deleteWorkspace, type Workspace } from '../api/workspaces'; import { fetchWorkspaces, deleteWorkspace, type Workspace } from '../api/workspaces';
import { useTranslation } from '../hooks/useTranslation'; import { useTranslation } from '../hooks/useTranslation';
@@ -17,6 +17,7 @@ import { Input } from '../components/ui/input';
import { Card, CardContent, CardTitle } from '../components/ui/card'; import { Card, CardContent, CardTitle } from '../components/ui/card';
import { Pagination } from '../components/Pagination'; import { Pagination } from '../components/Pagination';
import { Modal } from '../components/Modal'; import { Modal } from '../components/Modal';
import { readNumberParam, readStringParam, updateQueryParams } from '../lib/queryParams';
const RoleBadge = ({ role }: { role?: WorkspaceRole }) => { const RoleBadge = ({ role }: { role?: WorkspaceRole }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -39,18 +40,18 @@ const RoleBadge = ({ role }: { role?: WorkspaceRole }) => {
export default function Workspaces() { export default function Workspaces() {
const [workspaces, setWorkspaces] = useState<Workspace[]>([]); const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [ordering, setOrdering] = useState('-created_at');
const [currentPage, setCurrentPage] = useState(1);
const [totalItems, setTotalItems] = useState(0); const [totalItems, setTotalItems] = useState(0);
const [limit, setLimit] = useState(10);
const [deleteModal, setDeleteModal] = useState<{isOpen: boolean; workspace: Workspace | null}>({isOpen: false, workspace: null}); const [deleteModal, setDeleteModal] = useState<{isOpen: boolean; workspace: Workspace | null}>({isOpen: false, workspace: null});
const [deleteInput, setDeleteInput] = useState(''); const [deleteInput, setDeleteInput] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { t } = useTranslation(); const { t } = useTranslation();
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));
const orderingOptions = [ const orderingOptions = [
{ value: '-created_at', label: t.ordering?.createdAtDesc || 'Newest First' }, { value: '-created_at', label: t.ordering?.createdAtDesc || 'Newest First' },
@@ -60,10 +61,6 @@ export default function Workspaces() {
{ value: '-updated_at', label: t.ordering?.updatedAtDesc || 'Recently Updated' }, { value: '-updated_at', label: t.ordering?.updatedAtDesc || 'Recently Updated' },
]; ];
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, ordering]);
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
loadWorkspaces(); loadWorkspaces();
@@ -116,6 +113,19 @@ export default function Workspaces() {
} }
}; };
const updateListParams = (updates: Record<string, string | number | null | undefined>) => {
setSearchParams(
(current) =>
updateQueryParams(current, updates, {
search: '',
ordering: '-created_at',
page: 1,
limit: 10,
}),
{ replace: true },
);
};
return ( return (
<div className="mx-auto flex min-h-full max-w-7xl flex-col p-4 md:p-6"> <div className="mx-auto flex min-h-full max-w-7xl flex-col p-4 md:p-6">
<div className="flex flex-1 flex-col gap-5"> <div className="flex flex-1 flex-col gap-5">
@@ -139,9 +149,9 @@ export default function Workspaces() {
<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"> <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 <FilterBar
searchQuery={searchQuery} searchQuery={searchQuery}
setSearchQuery={setSearchQuery} setSearchQuery={(value) => updateListParams({ search: value, page: 1 })}
ordering={ordering} ordering={ordering}
setOrdering={setOrdering} setOrdering={(value) => updateListParams({ ordering: value, page: 1 })}
orderingOptions={orderingOptions} orderingOptions={orderingOptions}
searchPlaceholder={t.workspace?.searchPlaceholder || 'Search...'} searchPlaceholder={t.workspace?.searchPlaceholder || 'Search...'}
/> />
@@ -220,10 +230,12 @@ export default function Workspaces() {
})} })}
{workspaces.length === 0 && ( {workspaces.length === 0 && (
<div className="flex flex-1 rounded-3xl border-2 border-dashed border-slate-200 bg-white py-16 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">
<div className="flex flex-col items-center justify-center"> <LayoutDashboard className="mx-auto mb-3 h-12 w-12 text-slate-300 dark:text-slate-700" />
<p className="text-slate-500 dark:text-slate-400 font-medium">{t.workspace?.emptyState || 'No workspaces found'}</p> <h3 className="text-lg font-medium text-slate-900 dark:text-white">{t.workspace.noWorkspace}</h3>
</div> <p className="mt-1 text-slate-500 dark:text-slate-400">
{searchQuery ? t.workspace.noWorkspaceSearch : t.workspace?.emptyState}
</p>
</div> </div>
)} )}
</div> </div>
@@ -232,8 +244,8 @@ export default function Workspaces() {
currentPage={currentPage} currentPage={currentPage}
totalCount={totalItems} totalCount={totalItems}
limit={limit} limit={limit}
onPageChange={setCurrentPage} onPageChange={(page) => updateListParams({ page })}
onLimitChange={setLimit} onLimitChange={(pageLimit) => updateListParams({ limit: pageLimit, page: 1 })}
pageSizeOptions={[10, 20, 50]} pageSizeOptions={[10, 20, 50]}
/> />
</div> </div>