From 6bf60c8e071da2653186713388cc9a177896a78e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 9 Apr 2026 17:00:30 +0200 Subject: [PATCH] feat(web): persist list-page filters in URL search params Resources, projects, and allocations filter state now syncs to/from URL so filters survive refresh and can be shared via link. Text inputs are debounced (300ms) to avoid URL churn. Co-Authored-By: Claude Sonnet 4.6 --- .../src/app/(app)/projects/ProjectsClient.tsx | 35 ++++++-- .../app/(app)/resources/ResourcesClient.tsx | 83 +++++++++++++------ .../allocations/AllocationsClient.tsx | 41 +++++---- apps/web/src/hooks/useUrlFilters.ts | 59 +++++++++++++ 4 files changed, 172 insertions(+), 46 deletions(-) create mode 100644 apps/web/src/hooks/useUrlFilters.ts diff --git a/apps/web/src/app/(app)/projects/ProjectsClient.tsx b/apps/web/src/app/(app)/projects/ProjectsClient.tsx index 0c45eb8..a0af1b4 100644 --- a/apps/web/src/app/(app)/projects/ProjectsClient.tsx +++ b/apps/web/src/app/(app)/projects/ProjectsClient.tsx @@ -1,6 +1,8 @@ "use client"; import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { useUrlFilters } from "~/hooks/useUrlFilters.js"; +import { useDebounce } from "~/hooks/useDebounce.js"; import { createPortal } from "react-dom"; import { formatDate, formatMoney } from "~/lib/format.js"; import type { Project, ColumnDef } from "@capakraken/shared"; @@ -177,9 +179,28 @@ interface ProjectRow { // ─── Main component ─────────────────────────────────────────────────────────── export function ProjectsClient() { - const [search, setSearch] = useState(""); - const [statusFilter, setStatusFilter] = useState(""); - const [orderTypeFilter, setOrderTypeFilter] = useState(""); + const [filters, setFilters] = useUrlFilters({ search: "", status: "", orderType: "" }); + const { status: statusFilter, orderType: orderTypeFilter } = filters; + + // Debounced local state for the search text input + const [searchInput, setSearchInput] = useState(filters.search); + const debouncedSearch = useDebounce(searchInput, 300); + const search = filters.search; + + // Flush debounced input to URL + useEffect(() => { + setFilters({ search: debouncedSearch }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearch]); + + // Keep local input in sync when URL changes externally (e.g. back/forward) + useEffect(() => { + setSearchInput(filters.search); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filters.search]); + + const setStatusFilter = useCallback((v: string) => setFilters({ status: v }), [setFilters]); + const setOrderTypeFilter = useCallback((v: string) => setFilters({ orderType: v }), [setFilters]); const [modalOpen, setModalOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false); const [editingProject, setEditingProject] = useState(null); @@ -346,7 +367,7 @@ export function ProjectsClient() { function openNewModal() { setEditingProject(null); setModalOpen(true); } function openEditModal(project: Project) { setEditingProject(project); setModalOpen(true); } function closeModal() { setModalOpen(false); setEditingProject(null); } - function clearAll() { setSearch(""); setStatusFilter(""); setOrderTypeFilter(""); } + function clearAll() { setSearchInput(""); setFilters({ search: "", status: "", orderType: "" }); } const exportSelectedCsv = useCallback(() => { const selected = projects.filter((p) => selection.selectedIds.has(p.id)); @@ -368,7 +389,7 @@ export function ProjectsClient() { }, [projects, selection.selectedIds]); const chips = [ - ...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []), + ...(search ? [{ label: `Search: "${search}"`, onRemove: () => { setSearchInput(""); setFilters({ search: "" }); } }] : []), ...(statusFilter ? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("") }] : []), ...(orderTypeFilter ? [{ label: `Type: ${orderTypeFilter}`, onRemove: () => setOrderTypeFilter("") }] : []), ]; @@ -531,8 +552,8 @@ export function ProjectsClient() { setSearch(e.target.value)} + value={searchInput} + onChange={(e) => setSearchInput(e.target.value)} className="app-input max-w-xs" /> setSearch(e.target.value)} + value={searchInput} + onChange={(e) => setSearchInput(e.target.value)} className="app-input max-w-xs" /> (null); - const [filterProjectId, setFilterProjectId] = useState(""); - const [filterResourceId, setFilterResourceId] = useState(""); - const [filterStatus, setFilterStatus] = useState(""); - const [hidePastProjects, setHidePastProjects] = useState(true); - const [hideCompletedProjects, setHideCompletedProjects] = useState(true); - const [hideDraftProjects, setHideDraftProjects] = useState(false); + + const [allocFilters, setAllocFilters] = useUrlFilters({ + projectId: "", + resourceId: "", + status: "", + hidePast: "true", + hideCompleted: "true", + hideDraft: "false", + }); + + const filterProjectId = allocFilters.projectId; + const filterResourceId = allocFilters.resourceId; + const filterStatus = allocFilters.status; + const hidePastProjects = allocFilters.hidePast === "true"; + const hideCompletedProjects = allocFilters.hideCompleted === "true"; + const hideDraftProjects = allocFilters.hideDraft === "true"; + + const setFilterProjectId = useCallback((v: string) => setAllocFilters({ projectId: v }), [setAllocFilters]); + const setFilterResourceId = useCallback((v: string) => setAllocFilters({ resourceId: v }), [setAllocFilters]); + const setFilterStatus = useCallback((v: string) => setAllocFilters({ status: v }), [setAllocFilters]); + const setHidePastProjects = useCallback((v: boolean) => setAllocFilters({ hidePast: v ? "true" : "false" }), [setAllocFilters]); + const setHideCompletedProjects = useCallback((v: boolean) => setAllocFilters({ hideCompleted: v ? "true" : "false" }), [setAllocFilters]); + const setHideDraftProjects = useCallback((v: boolean) => setAllocFilters({ hideDraft: v ? "true" : "false" }), [setAllocFilters]); const [confirmDelete, setConfirmDelete] = useState<{ single?: AllocationWithDetails; ids?: string[] } | null>(null); const [batchStatusPicker, setBatchStatusPicker] = useState(false); const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null); @@ -405,12 +423,7 @@ export function AllocationsClient() { } function clearAll() { - setFilterProjectId(""); - setFilterResourceId(""); - setFilterStatus(""); - setHidePastProjects(false); - setHideCompletedProjects(false); - setHideDraftProjects(false); + setAllocFilters({ projectId: "", resourceId: "", status: "", hidePast: "false", hideCompleted: "false", hideDraft: "false" }); } const chips = [ @@ -450,9 +463,7 @@ export function AllocationsClient() { hideDraftProjects, }) ) { - setHidePastProjects(false); - setHideCompletedProjects(false); - setHideDraftProjects(false); + setAllocFilters({ hidePast: "false", hideCompleted: "false", hideDraft: "false" }); } }, [ assignmentList.length, diff --git a/apps/web/src/hooks/useUrlFilters.ts b/apps/web/src/hooks/useUrlFilters.ts new file mode 100644 index 0000000..5b45451 --- /dev/null +++ b/apps/web/src/hooks/useUrlFilters.ts @@ -0,0 +1,59 @@ +"use client"; + +import { useRouter, useSearchParams, usePathname } from "next/navigation"; +import { useCallback, useRef, useTransition } from "react"; + +// Typed-routes are enabled in next.config.ts; bypass for dynamic URLs. +type PlainRouter = { replace: (url: string, opts?: { scroll?: boolean }) => void }; + +/** + * Syncs a filter object with URL search params. + * T must be a flat record of string | undefined values. + * + * - Reads current filter values from URL search params, falling back to `defaults`. + * - Returns a stable `setFilters` updater that writes to the URL via `router.replace` + * (no back-button history entry per keystroke). + * - Default values are NOT written to the URL, keeping URLs clean and shareable. + */ +export function useUrlFilters>( + defaults: T, +): [T, (updates: Partial) => void] { + const searchParams = useSearchParams(); + const router = useRouter() as unknown as PlainRouter; + const pathname = usePathname(); + const [, startTransition] = useTransition(); + + // Stable ref so setFilters doesn't need defaults in its deps array. + const defaultsRef = useRef(defaults); + defaultsRef.current = defaults; + + // Read current values from URL, falling back to defaults + const filters = Object.fromEntries( + Object.keys(defaults).map((key) => { + const val = searchParams.get(key); + return [key, val ?? defaults[key]]; + }), + ) as T; + + const setFilters = useCallback( + (updates: Partial) => { + const params = new URLSearchParams(searchParams.toString()); + const defs = defaultsRef.current; + + for (const [key, value] of Object.entries(updates)) { + if (value === undefined || value === defs[key as keyof T]) { + params.delete(key); // Clean URL — don't show default values + } else { + params.set(key, String(value)); + } + } + + startTransition(() => { + router.replace(`${pathname}?${params.toString()}`, { scroll: false }); + }); + }, + [searchParams, router, pathname], + ); + + return [filters, setFilters]; +}