feat(frontend): persist page filters in query params
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
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 { 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user