diff --git a/src/components/FilterBar.tsx b/src/components/FilterBar.tsx index 37f4dd6..5441f7b 100644 --- a/src/components/FilterBar.tsx +++ b/src/components/FilterBar.tsx @@ -1,6 +1,5 @@ -import { Search, ArrowUpDown } from 'lucide-react'; -import { Select } from './ui/Select'; -import { Input } from './ui/input'; +import { Search, ArrowUpDown } from 'lucide-react'; +import { Select } from './ui/Select'; interface FilterBarProps { searchQuery: string; @@ -18,20 +17,19 @@ export default function FilterBar({ setOrdering, orderingOptions, searchPlaceholder -}: FilterBarProps) { - - return ( +}: FilterBarProps) { + return (
setSearchQuery(e.target.value)} - placeholder={searchPlaceholder || "Search..."} - className="w-full pl-10 pr-4 rtl:pl-4 rtl:pr-10 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white outline-none focus:ring-2 focus:ring-blue-500 transition-shadow" - /> -
+ type="text" + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + placeholder={searchPlaceholder || "Search..."} + className="w-full pl-10 pr-4 rtl:pl-4 rtl:pr-10 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white outline-none focus:ring-2 focus:ring-blue-500 transition-shadow" + /> +
diff --git a/src/components/InfiniteScroll.tsx b/src/components/InfiniteScroll.tsx index c417dc4..3d51aee 100644 --- a/src/components/InfiniteScroll.tsx +++ b/src/components/InfiniteScroll.tsx @@ -1,4 +1,5 @@ -import React, { useEffect, useRef } from "react"; +import React, { useEffect, useRef } from "react"; +import { useTranslation } from "../hooks/useTranslation"; interface InfiniteScrollProps { children: React.ReactNode; @@ -16,8 +17,9 @@ export const InfiniteScroll: React.FC = ({ isLoading, className = "", loader, -}) => { - const observerTarget = useRef(null); +}) => { + const { t } = useTranslation(); + const observerTarget = useRef(null); const onLoadMoreRef = useRef(onLoadMore); const hasMoreRef = useRef(hasMore); const isLoadingRef = useRef(isLoading); @@ -56,11 +58,11 @@ export const InfiniteScroll: React.FC = ({ {isLoading && ( loader || ( -
- Loading... -
- ) - )} +
+ {t.loading || "Loading..."} +
+ ) + )}
); }; diff --git a/src/components/ui/SearchableSelect.tsx b/src/components/ui/SearchableSelect.tsx index 1121464..94e9c75 100644 --- a/src/components/ui/SearchableSelect.tsx +++ b/src/components/ui/SearchableSelect.tsx @@ -27,8 +27,8 @@ export function SearchableSelect({ onChange, options, placeholder = "", - searchPlaceholder = "Search...", - emptyLabel = "No results", + searchPlaceholder, + emptyLabel, disabled = false, className = "", buttonClassName = "", @@ -111,7 +111,7 @@ export function SearchableSelect({ setQuery(event.target.value)} - placeholder={searchPlaceholder} + placeholder={searchPlaceholder || "Search..."} className="h-9 pl-9" autoFocus /> @@ -138,7 +138,9 @@ export function SearchableSelect({ ))} {filteredOptions.length === 0 && ( -
{emptyLabel}
+
+ {emptyLabel || "No results"} +
)} , diff --git a/src/lib/queryParams.ts b/src/lib/queryParams.ts new file mode 100644 index 0000000..c057a1f --- /dev/null +++ b/src/lib/queryParams.ts @@ -0,0 +1,90 @@ +export type QueryParamUpdateValue = + | string + | number + | boolean + | null + | undefined + | Array; + +type QueryParamDefaults = Record; + +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, + 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; +}; diff --git a/src/lib/reportFilters.ts b/src/lib/reportFilters.ts new file mode 100644 index 0000000..9d7fe27 --- /dev/null +++ b/src/lib/reportFilters.ts @@ -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: "", + }, + ); diff --git a/src/pages/Clients.tsx b/src/pages/Clients.tsx index 5872e02..de226c8 100644 --- a/src/pages/Clients.tsx +++ b/src/pages/Clients.tsx @@ -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([]) - 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(null) - const [deleteClient, setDeleteClient] = useState(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(null) + const [deleteClient, setDeleteClient] = useState(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) => { + setSearchParams( + (current) => + updateQueryParams(current, updates, { + search: "", + ordering: "-created_at", + page: 1, + limit: 10, + }), + { replace: true }, + ) + } + if (!activeWorkspace) { return (
@@ -148,9 +156,9 @@ export default function Clients() {
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() { ) : (
{clients.length === 0 ? ( -
+

{t.clients.noClients}

@@ -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 })} /> )}

@@ -248,30 +256,30 @@ export default function Clients() { {canCreateClient && ( setIsCreateModalOpen(false)} - onSuccess={fetchClientsList} - workspaceId={activeWorkspace.id} - /> - )} - - {canEditClient && ( - setEditClient(null)} - onSuccess={fetchClientsList} - client={editClient} - /> - )} - + isOpen={isCreateModalOpen} + onClose={() => setIsCreateModalOpen(false)} + onSuccess={fetchClientsList} + workspaceId={activeWorkspace.id} + /> + )} + + {canEditClient && ( + setEditClient(null)} + onSuccess={fetchClientsList} + client={editClient} + /> + )} + {!!deleteClient && ( setDeleteClient(null)} onSuccess={fetchClientsList} client={deleteClient} - /> - )} -
- ) -} + /> + )} +
+ ) +} diff --git a/src/pages/Logs.tsx b/src/pages/Logs.tsx index 166140f..2248f8f 100644 --- a/src/pages/Logs.tsx +++ b/src/pages/Logs.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "react-router-dom"; import { History, ShieldCheck, SlidersHorizontal } from "lucide-react"; import { toast } from "sonner"; @@ -14,6 +15,7 @@ import { LogsFeed } from "../components/logs/LogsFeed"; import { LogsFilterBar, type LogsFilterDraft } from "../components/logs/LogsFilterBar"; import { useWorkspace } from "../context/WorkspaceContext"; import { useTranslation } from "../hooks/useTranslation"; +import { readStringParam, updateQueryParams } from "../lib/queryParams"; import { canWorkspace, WORKSPACE_LOGS_VIEW } from "../lib/permissions"; const DEFAULT_FILTERS: LogsFilterDraft = { @@ -26,12 +28,22 @@ const DEFAULT_FILTERS: LogsFilterDraft = { ordering: "-timestamp", }; +const DEFAULT_QUERY_FILTERS: Record = { + 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; export default function Logs() { const { t, lang } = useTranslation(); const { activeWorkspace } = useWorkspace(); - const [filters, setFilters] = useState(DEFAULT_FILTERS); + const [searchParams, setSearchParams] = useSearchParams(); const [memberships, setMemberships] = useState([]); const [logs, setLogs] = useState([]); const [totalLogs, setTotalLogs] = useState(0); @@ -45,9 +57,20 @@ export default function Logs() { const workspaceRole = activeWorkspace?.my_role; const canViewLogs = canWorkspace(workspaceRole, WORKSPACE_LOGS_VIEW); const isWorkspaceRoleResolved = Boolean(workspaceRole); + const filters = useMemo( + () => ({ + 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(() => { - setFilters(DEFAULT_FILTERS); setLogs([]); setTotalLogs(0); setSelectedLogId(null); @@ -284,7 +307,25 @@ export default function Logs() { users={memberships} isLoadingUsers={isLoadingUsers} 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 }, + ) + } /> { const { t, lang } = useTranslation(); const { user } = useAppContext(); @@ -32,24 +40,44 @@ export const Projects: React.FC = () => { const canCreateProject = canWorkspace(workspaceRole, PROJECTS_CREATE); const canEditProject = canWorkspace(workspaceRole, PROJECTS_EDIT); const canArchiveProject = canWorkspace(workspaceRole, PROJECTS_ARCHIVE); - + const [projects, setProjects] = useState([]); const [clients, setClients] = useState<{ id: string; name: string }[]>([]); const [loading, setLoading] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [editingProject, setEditingProject] = useState(null); - const [search, setSearch] = useState(""); - const [ordering, setOrdering] = useState("-created_at"); - const [isArchived, setIsArchived] = useState(false); - const [selectedClientIds, setSelectedClientIds] = useState([]); - const [currentPage, setCurrentPage] = useState(1); - const [limit, setLimit] = useState(10); + const [searchParams, setSearchParams] = useSearchParams(); + const search = useMemo(() => readStringParam(searchParams, "search", ""), [searchParams]); + const ordering = useMemo( + () => readStringParam(searchParams, "ordering", "-created_at"), + [searchParams], + ); + 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 [deleteModal, setDeleteModal] = useState<{isOpen: boolean; project: Project | null}>({isOpen: false, project: null}); const [deleteInput, setDeleteInput] = useState(''); - + const orderingOptions = [ { value: '-created_at', label: t.ordering?.createdAtDesc || 'Newest First' }, { value: 'created_at', label: t.ordering?.createdAt || 'Oldest First' }, @@ -57,10 +85,6 @@ export const Projects: React.FC = () => { { value: '-name', label: t.ordering?.nameDesc || 'Name (Z-A)' }, ]; - useEffect(() => { - setCurrentPage(1); - }, [search, ordering, isArchived, selectedClientIds]); - const fetchProjectList = async () => { if (!activeWorkspace) return; setLoading(true); @@ -74,8 +98,8 @@ export const Projects: React.FC = () => { is_archived: isArchived, ordering }); - const items = data?.results || (Array.isArray(data) ? data : []) - const count = data?.count !== undefined ? data.count : items.length + const items = data?.results || (Array.isArray(data) ? data : []) + const count = data?.count !== undefined ? data.count : items.length setProjects(items); setTotalItems(count) } catch (error) { @@ -106,52 +130,52 @@ export const Projects: React.FC = () => { void fetchProjectList(); }, 300); return () => clearTimeout(delayDebounceFn); - }, [activeWorkspace, currentPage, limit, search, isArchived, ordering, selectedClientIds]); + }, [activeWorkspace?.id, currentPage, limit, search, isArchived, ordering, selectedClientIdsKey]); useEffect(() => { const handleCreated = () => void fetchProjectList(); const handleUpdated = () => void fetchProjectList(); - - window.addEventListener("project_created", handleCreated); - window.addEventListener("project_updated", handleUpdated); - - return () => { - window.removeEventListener("project_created", handleCreated); - window.removeEventListener("project_updated", handleUpdated); - }; - }, [activeWorkspace, currentPage, limit, search, isArchived, ordering]); - + + window.addEventListener("project_created", handleCreated); + window.addEventListener("project_updated", handleUpdated); + + return () => { + window.removeEventListener("project_created", handleCreated); + window.removeEventListener("project_updated", handleUpdated); + }; + }, [activeWorkspace?.id, currentPage, limit, search, isArchived, ordering, selectedClientIdsKey]); + const confirmDelete = async () => { if (!deleteModal.project) return; try { - const deletedId = deleteModal.project.id; - await deleteProject(deletedId); - - fetchProjectList(); - - window.dispatchEvent(new CustomEvent('project_deleted', { - detail: { id: deletedId } - })); - - toast.success(t.projects?.deleteSuccess || 'Project deleted successfully'); - setDeleteModal({ isOpen: false, project: null }); - setDeleteInput(''); - } catch (error) { - toast.error(t.projects?.deleteError || 'Failed to delete project'); - } - }; - + const deletedId = deleteModal.project.id; + await deleteProject(deletedId); + + fetchProjectList(); + + window.dispatchEvent(new CustomEvent('project_deleted', { + detail: { id: deletedId } + })); + + toast.success(t.projects?.deleteSuccess || 'Project deleted successfully'); + setDeleteModal({ isOpen: false, project: null }); + setDeleteInput(''); + } catch (error) { + toast.error(t.projects?.deleteError || 'Failed to delete project'); + } + }; + const formatDate = (dateStr: string | undefined) => { - if (!dateStr) return "-" - try { - const date = new Date(dateStr) - return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR" : "en-US", { - dateStyle: "long", - timeZone: "Asia/Tehran", - }).format(date) - } catch { - return dateStr - } + if (!dateStr) return "-" + try { + const date = new Date(dateStr) + return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR" : "en-US", { + dateStyle: "long", + timeZone: "Asia/Tehran", + }).format(date) + } catch { + return dateStr + } } const sortedClients = useMemo(() => { @@ -159,14 +183,29 @@ export const Projects: React.FC = () => { const selected = clients.filter((client) => selectedClientIds.includes(client.id)); const unselected = clients.filter((client) => !selectedClientIds.includes(client.id)); return [...selected, ...unselected]; - }, [clients, selectedClientIds]); + }, [clients, selectedClientIdsKey]); const toggleClientFilter = (clientId: string) => { - setCurrentPage(1); - setSelectedClientIds((current) => - current.includes(clientId) - ? current.filter((id) => id !== clientId) - : [...current, clientId], + const nextClientIds = selectedClientIds.includes(clientId) + ? selectedClientIds.filter((id) => id !== clientId) + : [...selectedClientIds, clientId]; + + updateListParams({ clients: nextClientIds, page: 1 }); + }; + + const updateListParams = ( + updates: Record, + ) => { + 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 && ( - - - } - > -
-

- {t.projects?.deleteWarning || 'To confirm deletion, please type the project name:'} {deleteModal.project.name} -

- - setDeleteInput(e.target.value)} - placeholder={deleteModal.project.name} - /> -
- - )} - -
- ); - -}; + setIsCreateModalOpen(false)} + /> + )} + + {canEditProject && editingProject && ( + setEditingProject(null)} + /> + )} + + {deleteModal.project && ( + { + setDeleteModal({ isOpen: false, project: null }); + setDeleteInput(''); + }} + title={t.projects?.deleteTitle || 'Delete Project'} + maxWidth="max-w-md" + footer={ + <> + + + + } + > +
+

+ {t.projects?.deleteWarning || 'To confirm deletion, please type the project name:'} {deleteModal.project.name} +

+ + setDeleteInput(e.target.value)} + placeholder={deleteModal.project.name} + /> +
+
+ )} + +
+ ); + +}; diff --git a/src/pages/Reports.tsx b/src/pages/Reports.tsx index 938c9a9..2641634 100644 --- a/src/pages/Reports.tsx +++ b/src/pages/Reports.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "react-router-dom"; import { BarChart3, Table2 } from "lucide-react"; import { toast } from "sonner"; @@ -21,6 +22,12 @@ import { ReportsFilterBar, type ReportsFilterDraft } from "../components/reports import { ReportsTablePanel } from "../components/reports/ReportsTablePanel"; import { useWorkspace } from "../context/WorkspaceContext"; 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"; type Tab = "chart" | "table"; @@ -86,7 +93,8 @@ const getCurrentLanguageAwareMonthRange = (lang: "en" | "fa") => { export default function Reports() { const { t, lang } = useTranslation(); const { activeWorkspace } = useWorkspace(); - const [tab, setTab] = useState("chart"); + const [searchParams, setSearchParams] = useSearchParams(); + const tab = (readStringParam(searchParams, "tab", "chart") as Tab); const [projects, setProjects] = useState([]); const [clients, setClients] = useState<{ id: string; name: string }[]>([]); const [tags, setTags] = useState([]); @@ -105,16 +113,10 @@ export default function Reports() { const canSelectUsers = canWorkspace(activeWorkspace?.my_role, WORKSPACE_MEMBERS_VIEW); const isWorkspaceRoleResolved = Boolean(activeWorkspace?.my_role); const showUserFilterLoading = !isWorkspaceRoleResolved || (canSelectUsers && isLoadingUsers); - - const [filters, setFilters] = useState({ - period: "this_month", - from_date: "", - to_date: "", - user: "", - client: "", - project: "", - tags: [], - }); + const filters = useMemo( + () => readReportsFiltersFromParams(searchParams), + [searchParams], + ); useEffect(() => { if (!activeWorkspace?.id) return; @@ -177,6 +179,7 @@ export default function Reports() { }; const apiFilters = useMemo(() => buildApiFilters(filters), [activeWorkspace?.id, canSelectUsers, filters, lang]); + const apiFiltersKey = apiFilters ? JSON.stringify(apiFilters) : ""; const runReportLoad = async (nextFilters: ReportFilters) => { setIsLoading(true); @@ -199,8 +202,7 @@ export default function Reports() { useEffect(() => { if (!apiFilters) return; void runReportLoad(apiFilters); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [apiFilters?.workspace]); + }, [apiFilters, apiFiltersKey]); const handleToggleDay = async (day: string) => { if (!apiFilters) return; @@ -283,7 +285,12 @@ export default function Reports() {