feat(frontend): persist page filters in query params

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

View File

@@ -1,6 +1,5 @@
import { Search, ArrowUpDown } from 'lucide-react';
import { 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 (
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 rtl:left-auto rtl:right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-400" />
<input
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"
/>
</div>
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"
/>
</div>
<div className="flex w-full items-center gap-2 sm:w-auto">
<ArrowUpDown className="h-5 w-5 text-slate-400 hidden sm:block" />

View File

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

View File

@@ -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({
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={searchPlaceholder}
placeholder={searchPlaceholder || "Search..."}
className="h-9 pl-9"
autoFocus
/>
@@ -138,7 +138,9 @@ export function SearchableSelect({
</button>
))}
{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>,

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

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

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

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

View File

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

View File

@@ -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<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;
export default function Logs() {
const { t, lang } = useTranslation();
const { activeWorkspace } = useWorkspace();
const [filters, setFilters] = useState<LogsFilterDraft>(DEFAULT_FILTERS);
const [searchParams, setSearchParams] = useSearchParams();
const [memberships, setMemberships] = useState<WorkspaceMembership[]>([]);
const [logs, setLogs] = useState<WorkspaceLogItem[]>([]);
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<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(() => {
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 },
)
}
/>
<LogsFeed

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "../hooks/useTranslation";
import { getProjects, deleteProject, type Project } from "../api/projects";
import { getClients } from "../api/clients";
@@ -16,14 +17,21 @@ import { Card, CardContent, CardTitle } from "../components/ui/card";
import { Modal } from "../components/Modal";
import { toast } from "sonner";
import { Input } from "../components/ui/input";
import {
import {
PROJECTS_ARCHIVE,
PROJECTS_CREATE,
PROJECTS_EDIT,
canDeleteWorkspaceResource,
canWorkspace,
} from "../lib/permissions";
import {
readArrayParam,
readBooleanParam,
readNumberParam,
readStringParam,
updateQueryParams,
} from "../lib/queryParams";
export const Projects: React.FC = () => {
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<Project[]>([]);
const [clients, setClients] = useState<{ id: string; name: string }[]>([]);
const [loading, setLoading] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [search, setSearch] = useState("");
const [ordering, setOrdering] = useState("-created_at");
const [isArchived, setIsArchived] = useState(false);
const [selectedClientIds, setSelectedClientIds] = useState<string[]>([]);
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<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 && (
<Button
variant={isArchived ? "default" : "secondary"}
onClick={() => setIsArchived(!isArchived)}
onClick={() => updateListParams({ archived: !isArchived, page: 1 })}
className="flex-1 gap-2 shadow-sm sm:flex-none"
>
<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">
<FilterBar
searchQuery={search}
setSearchQuery={setSearch}
setSearchQuery={(value) => updateListParams({ search: value, page: 1 })}
ordering={ordering}
setOrdering={setOrdering}
setOrdering={(value) => updateListParams({ ordering: value, page: 1 })}
orderingOptions={orderingOptions}
searchPlaceholder={t.projects?.searchPlaceholder || 'Search projects...'}
/>
@@ -233,8 +272,7 @@ export const Projects: React.FC = () => {
<button
type="button"
onClick={() => {
setCurrentPage(1);
setSelectedClientIds([]);
updateListParams({ clients: [], page: 1 });
}}
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">
{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" />
<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 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}
totalCount={totalItems}
limit={limit}
onPageChange={setCurrentPage}
onLimitChange={setLimit}
onPageChange={(page) => updateListParams({ page })}
onLimitChange={(pageLimit) => updateListParams({ limit: pageLimit, page: 1 })}
pageSizeOptions={[10, 20, 50]}
/>
</div>
@@ -384,68 +423,68 @@ export const Projects: React.FC = () => {
{/* Modals */}
{canCreateProject && isCreateModalOpen && (
<ProjectCreateModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
/>
)}
{canEditProject && editingProject && (
<ProjectEditModal
project={editingProject}
isOpen={!!editingProject}
onClose={() => setEditingProject(null)}
/>
)}
{deleteModal.project && (
<Modal
isOpen={deleteModal.isOpen}
onClose={() => {
setDeleteModal({ isOpen: false, project: null });
setDeleteInput('');
}}
title={t.projects?.deleteTitle || 'Delete Project'}
maxWidth="max-w-md"
footer={
<>
<Button
variant="secondary"
onClick={() => {
setDeleteModal({ isOpen: false, project: null });
setDeleteInput('');
}}
className="rounded-xl font-semibold"
>
{t.actions?.cancel || 'Cancel'}
</Button>
<Button
variant="destructive"
disabled={deleteInput !== deleteModal.project.name}
onClick={confirmDelete}
className="rounded-xl font-semibold"
>
{t.actions?.delete || 'Delete'}
</Button>
</>
}
>
<div className="flex flex-col gap-4">
<p className="text-slate-600 dark:text-slate-400 text-sm leading-relaxed">
{t.projects?.deleteWarning || 'To confirm deletion, please type the project name:'} <strong className="text-slate-900 dark:text-white select-all">{deleteModal.project.name}</strong>
</p>
<Input
type="text"
value={deleteInput}
onChange={(e) => setDeleteInput(e.target.value)}
placeholder={deleteModal.project.name}
/>
</div>
</Modal>
)}
</div>
);
};
<ProjectCreateModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
/>
)}
{canEditProject && editingProject && (
<ProjectEditModal
project={editingProject}
isOpen={!!editingProject}
onClose={() => setEditingProject(null)}
/>
)}
{deleteModal.project && (
<Modal
isOpen={deleteModal.isOpen}
onClose={() => {
setDeleteModal({ isOpen: false, project: null });
setDeleteInput('');
}}
title={t.projects?.deleteTitle || 'Delete Project'}
maxWidth="max-w-md"
footer={
<>
<Button
variant="secondary"
onClick={() => {
setDeleteModal({ isOpen: false, project: null });
setDeleteInput('');
}}
className="rounded-xl font-semibold"
>
{t.actions?.cancel || 'Cancel'}
</Button>
<Button
variant="destructive"
disabled={deleteInput !== deleteModal.project.name}
onClick={confirmDelete}
className="rounded-xl font-semibold"
>
{t.actions?.delete || 'Delete'}
</Button>
</>
}
>
<div className="flex flex-col gap-4">
<p className="text-slate-600 dark:text-slate-400 text-sm leading-relaxed">
{t.projects?.deleteWarning || 'To confirm deletion, please type the project name:'} <strong className="text-slate-900 dark:text-white select-all">{deleteModal.project.name}</strong>
</p>
<Input
type="text"
value={deleteInput}
onChange={(e) => setDeleteInput(e.target.value)}
placeholder={deleteModal.project.name}
/>
</div>
</Modal>
)}
</div>
);
};

View File

@@ -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<Tab>("chart");
const [searchParams, setSearchParams] = useSearchParams();
const tab = (readStringParam(searchParams, "tab", "chart") as Tab);
const [projects, setProjects] = useState<Project[]>([]);
const [clients, setClients] = useState<{ id: string; name: string }[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
@@ -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<ReportsFilterDraft>({
period: "this_month",
from_date: "",
to_date: "",
user: "",
client: "",
project: "",
tags: [],
});
const filters = useMemo<ReportsFilterDraft>(
() => readReportsFiltersFromParams(searchParams),
[searchParams],
);
useEffect(() => {
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 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() {
<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
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 ${
tab === "chart"
? "bg-white text-slate-900 shadow-sm dark:bg-slate-900 dark:text-white"
@@ -295,7 +302,12 @@ export default function Reports() {
</button>
<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 ${
tab === "table"
? "bg-white text-slate-900 shadow-sm dark:bg-slate-900 dark:text-white"
@@ -311,12 +323,12 @@ export default function Reports() {
<ReportsFilterBar
value={filters}
onApply={(draft) => {
setFilters(draft);
const nextFilters = buildApiFilters(draft);
if (!nextFilters) return;
void runReportLoad(nextFilters);
}}
onApply={(draft) =>
setSearchParams(
(current) => writeReportsFiltersToParams(current, draft),
{ replace: true },
)
}
projects={projects}
clients={clients}
tags={tags}

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Edit2, Plus, Tag as TagIcon, Trash2 } from "lucide-react";
import { toast } from "sonner";
@@ -14,6 +15,7 @@ import { Pagination } from "../components/Pagination";
import { Button } from "../components/ui/button";
import { Card, CardContent, CardTitle } from "../components/ui/card";
import { Input } from "../components/ui/input";
import { readNumberParam, readStringParam, updateQueryParams } from "../lib/queryParams";
const DEFAULT_COLOR = "#3B82F6";
@@ -24,14 +26,15 @@ export default function Tags() {
const workspaceRole = activeWorkspace?.my_role;
const canCreateTag = canWorkspace(workspaceRole, TAGS_CREATE);
const canEditTag = canWorkspace(workspaceRole, TAGS_EDIT);
const [searchParams, setSearchParams] = useSearchParams();
const [tags, setTags] = useState<Tag[]>([]);
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 [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 [editingTag, setEditingTag] = useState<Tag | null>(null);
@@ -48,10 +51,6 @@ export default function Tags() {
{ value: "-name", label: t.ordering?.nameDesc || "Name (Z-A)" },
];
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, ordering]);
useEffect(() => {
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) {
return (
<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">
<FilterBar
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
setSearchQuery={(value) => updateListParams({ search: value, page: 1 })}
ordering={ordering}
setOrdering={setOrdering}
setOrdering={(value) => updateListParams({ ordering: value, page: 1 })}
orderingOptions={orderingOptions}
searchPlaceholder={t.tags?.searchPlaceholder || "Search tags..."}
/>
@@ -238,9 +250,12 @@ export default function Tags() {
})}
{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">
<TagIcon className="w-10 h-10 mb-3" />
<p className="font-medium">{t.tags?.emptyState || "No tags found"}</p>
<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="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.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>
@@ -249,8 +264,8 @@ export default function Tags() {
currentPage={currentPage}
totalCount={totalItems}
limit={limit}
onPageChange={setCurrentPage}
onLimitChange={setLimit}
onPageChange={(page) => updateListParams({ page })}
onLimitChange={(pageLimit) => updateListParams({ limit: pageLimit, page: 1 })}
/>
</div>
)}

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
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 { toast } from "sonner";
@@ -25,6 +26,11 @@ import { Input } from "../components/ui/input";
import { SearchableSelect } from "../components/ui/SearchableSelect";
import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation";
import {
readArrayParam,
readStringParam,
updateQueryParams,
} from "../lib/queryParams";
type EntryModalMode = "manual" | "edit" | null;
@@ -2009,9 +2015,10 @@ function TimesheetSkeleton({ loadingLabel }: { loadingLabel: string }) {
}
export default function Timesheet() {
const { t, lang } = useTranslation();
const { activeWorkspace } = useWorkspace();
const isRtl = lang === "fa";
const { t, lang } = useTranslation();
const { activeWorkspace } = useWorkspace();
const [searchParams, setSearchParams] = useSearchParams();
const isRtl = lang === "fa";
const extendedTimesheet = (t.timesheet as {
deleteTitle?: string;
deleteConfirmMessage?: string;
@@ -2047,10 +2054,19 @@ export default function Timesheet() {
const [activeRunningEntry, setActiveRunningEntry] = useState<TimeEntry | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
const [filters, setFilters] = useState<TimeEntryFilters>(DEFAULT_ENTRY_FILTERS);
const [hasMoreHistory, setHasMoreHistory] = useState(false);
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 [nextOffset, setNextOffset] = useState<number | null>(0);
const [limit] = useState(20);
const [ticker, setTicker] = useState(Date.now());
@@ -2134,9 +2150,6 @@ export default function Timesheet() {
}, [activeWorkspace?.id, t.timesheet?.optionsError]);
useEffect(() => {
setSearchQuery("");
setDebouncedSearchQuery("");
setFilters(DEFAULT_ENTRY_FILTERS);
setGroupedHistory([]);
setNextOffset(0);
setHasMoreHistory(false);
@@ -2163,10 +2176,14 @@ export default function Timesheet() {
(project) => project.id === filters.projectId && project.client?.id === filters.clientId,
);
if (!projectStillMatchesClient) {
setFilters((current) => ({ ...current, projectId: "" }));
}
}, [filters.clientId, filters.projectId, projects]);
if (!projectStillMatchesClient) {
setSearchParams(
(current) =>
updateQueryParams(current, { project: "" }, { project: "" }),
{ replace: true },
);
}
}, [filters.clientId, filters.projectId, projects, setSearchParams]);
const loadHistory = useCallback(async ({ offset = 0, append = false }: { offset?: number; append?: boolean } = {}) => {
if (!activeWorkspace?.id) return;
@@ -2523,14 +2540,53 @@ export default function Timesheet() {
}, []);
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(() => {
setSearchQuery("");
setSearchParams(
(current) =>
updateQueryParams(
current,
{
search: "",
project: "",
client: "",
tags: [],
from: "",
to: "",
},
{
search: "",
project: "",
client: "",
from: "",
to: "",
},
),
{ replace: true },
);
setDebouncedSearchQuery("");
setFilters(DEFAULT_ENTRY_FILTERS);
}, []);
}, [setSearchParams]);
const handleLoadMore = useCallback(() => {
if (!hasMoreHistory || nextOffset === null || isLoadingMore) return;
@@ -2789,7 +2845,13 @@ export default function Timesheet() {
<TimesheetFilterBar
searchQuery={searchQuery}
filters={filters}
onSearchChange={setSearchQuery}
onSearchChange={(value) =>
setSearchParams(
(current) =>
updateQueryParams(current, { search: value }, { search: "" }),
{ replace: true },
)
}
onApply={handleApplyFilters}
onClearFilters={handleClearFilters}
projects={projects}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Trash2, Pencil, Eye } from 'lucide-react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Plus, Trash2, Pencil, Eye, LayoutDashboard } from 'lucide-react';
import { toast } from 'sonner';
import { fetchWorkspaces, deleteWorkspace, type Workspace } from '../api/workspaces';
import { useTranslation } from '../hooks/useTranslation';
@@ -17,6 +17,7 @@ import { Input } from '../components/ui/input';
import { Card, CardContent, CardTitle } from '../components/ui/card';
import { Pagination } from '../components/Pagination';
import { Modal } from '../components/Modal';
import { readNumberParam, readStringParam, updateQueryParams } from '../lib/queryParams';
const RoleBadge = ({ role }: { role?: WorkspaceRole }) => {
const { t } = useTranslation();
@@ -39,18 +40,18 @@ const RoleBadge = ({ role }: { role?: WorkspaceRole }) => {
export default function Workspaces() {
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
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 [limit, setLimit] = useState(10);
const [deleteModal, setDeleteModal] = useState<{isOpen: boolean; workspace: Workspace | null}>({isOpen: false, workspace: null});
const [deleteInput, setDeleteInput] = useState('');
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
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 = [
{ 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' },
];
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, ordering]);
useEffect(() => {
const timer = setTimeout(() => {
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 (
<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">
@@ -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">
<FilterBar
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
setSearchQuery={(value) => updateListParams({ search: value, page: 1 })}
ordering={ordering}
setOrdering={setOrdering}
setOrdering={(value) => updateListParams({ ordering: value, page: 1 })}
orderingOptions={orderingOptions}
searchPlaceholder={t.workspace?.searchPlaceholder || 'Search...'}
/>
@@ -220,10 +230,12 @@ export default function Workspaces() {
})}
{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 items-center justify-center">
<p className="text-slate-500 dark:text-slate-400 font-medium">{t.workspace?.emptyState || 'No workspaces found'}</p>
</div>
<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">
<LayoutDashboard 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.workspace.noWorkspace}</h3>
<p className="mt-1 text-slate-500 dark:text-slate-400">
{searchQuery ? t.workspace.noWorkspaceSearch : t.workspace?.emptyState}
</p>
</div>
)}
</div>
@@ -232,8 +244,8 @@ export default function Workspaces() {
currentPage={currentPage}
totalCount={totalItems}
limit={limit}
onPageChange={setCurrentPage}
onLimitChange={setLimit}
onPageChange={(page) => updateListParams({ page })}
onLimitChange={(pageLimit) => updateListParams({ limit: pageLimit, page: 1 })}
pageSizeOptions={[10, 20, 50]}
/>
</div>