feat(frontend): persist page filters in query params
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
90
src/lib/queryParams.ts
Normal 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
54
src/lib/reportFilters.ts
Normal 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: "",
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user