feat(frontend): persist page filters in query params
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
import { Search, ArrowUpDown } from 'lucide-react';
|
import { Search, ArrowUpDown } from 'lucide-react';
|
||||||
import { Select } from './ui/Select';
|
import { Select } from './ui/Select';
|
||||||
import { Input } from './ui/input';
|
|
||||||
|
|
||||||
interface FilterBarProps {
|
interface FilterBarProps {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -18,20 +17,19 @@ export default function FilterBar({
|
|||||||
setOrdering,
|
setOrdering,
|
||||||
orderingOptions,
|
orderingOptions,
|
||||||
searchPlaceholder
|
searchPlaceholder
|
||||||
}: FilterBarProps) {
|
}: FilterBarProps) {
|
||||||
|
return (
|
||||||
return (
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<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" />
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder={searchPlaceholder || "Search..."}
|
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"
|
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>
|
||||||
|
|
||||||
<div className="flex w-full items-center gap-2 sm:w-auto">
|
<div className="flex w-full items-center gap-2 sm:w-auto">
|
||||||
<ArrowUpDown className="h-5 w-5 text-slate-400 hidden sm:block" />
|
<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 {
|
interface InfiniteScrollProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -16,8 +17,9 @@ export const InfiniteScroll: React.FC<InfiniteScrollProps> = ({
|
|||||||
isLoading,
|
isLoading,
|
||||||
className = "",
|
className = "",
|
||||||
loader,
|
loader,
|
||||||
}) => {
|
}) => {
|
||||||
const observerTarget = useRef<HTMLDivElement>(null);
|
const { t } = useTranslation();
|
||||||
|
const observerTarget = useRef<HTMLDivElement>(null);
|
||||||
const onLoadMoreRef = useRef(onLoadMore);
|
const onLoadMoreRef = useRef(onLoadMore);
|
||||||
const hasMoreRef = useRef(hasMore);
|
const hasMoreRef = useRef(hasMore);
|
||||||
const isLoadingRef = useRef(isLoading);
|
const isLoadingRef = useRef(isLoading);
|
||||||
@@ -56,11 +58,11 @@ export const InfiniteScroll: React.FC<InfiniteScrollProps> = ({
|
|||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
loader || (
|
loader || (
|
||||||
<div className="py-2 text-center text-xs text-slate-500 dark:text-slate-400">
|
<div className="py-2 text-center text-xs text-slate-500 dark:text-slate-400">
|
||||||
Loading...
|
{t.loading || "Loading..."}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ export function SearchableSelect({
|
|||||||
onChange,
|
onChange,
|
||||||
options,
|
options,
|
||||||
placeholder = "",
|
placeholder = "",
|
||||||
searchPlaceholder = "Search...",
|
searchPlaceholder,
|
||||||
emptyLabel = "No results",
|
emptyLabel,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
className = "",
|
className = "",
|
||||||
buttonClassName = "",
|
buttonClassName = "",
|
||||||
@@ -111,7 +111,7 @@ export function SearchableSelect({
|
|||||||
<Input
|
<Input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
placeholder={searchPlaceholder}
|
placeholder={searchPlaceholder || "Search..."}
|
||||||
className="h-9 pl-9"
|
className="h-9 pl-9"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@@ -138,7 +138,9 @@ export function SearchableSelect({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{filteredOptions.length === 0 && (
|
{filteredOptions.length === 0 && (
|
||||||
<div className="px-3 py-3 text-sm text-slate-500 dark:text-slate-400">{emptyLabel}</div>
|
<div className="px-3 py-3 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{emptyLabel || "No results"}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
|
|||||||
90
src/lib/queryParams.ts
Normal file
90
src/lib/queryParams.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
export type QueryParamUpdateValue =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
| Array<string | number>;
|
||||||
|
|
||||||
|
type QueryParamDefaults = Record<string, string | number | boolean | undefined>;
|
||||||
|
|
||||||
|
const normalizeScalar = (value: string | number | boolean) => {
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value ? "1" : "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readStringParam = (
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
key: string,
|
||||||
|
fallback = "",
|
||||||
|
) => searchParams.get(key) ?? fallback;
|
||||||
|
|
||||||
|
export const readNumberParam = (
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
key: string,
|
||||||
|
fallback: number,
|
||||||
|
) => {
|
||||||
|
const rawValue = searchParams.get(key);
|
||||||
|
if (!rawValue) return fallback;
|
||||||
|
|
||||||
|
const parsedValue = Number(rawValue);
|
||||||
|
if (!Number.isFinite(parsedValue)) return fallback;
|
||||||
|
|
||||||
|
return parsedValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readBooleanParam = (
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
key: string,
|
||||||
|
fallback = false,
|
||||||
|
) => {
|
||||||
|
const rawValue = searchParams.get(key);
|
||||||
|
if (rawValue === null) return fallback;
|
||||||
|
|
||||||
|
return rawValue === "1" || rawValue === "true";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readArrayParam = (searchParams: URLSearchParams, key: string) =>
|
||||||
|
searchParams.getAll(key).filter(Boolean);
|
||||||
|
|
||||||
|
export const updateQueryParams = (
|
||||||
|
currentParams: URLSearchParams,
|
||||||
|
updates: Record<string, QueryParamUpdateValue>,
|
||||||
|
defaults: QueryParamDefaults = {},
|
||||||
|
) => {
|
||||||
|
const nextParams = new URLSearchParams(currentParams);
|
||||||
|
|
||||||
|
Object.entries(updates).forEach(([key, value]) => {
|
||||||
|
nextParams.delete(key);
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const normalizedValues = value
|
||||||
|
.map((item) => String(item).trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
normalizedValues.forEach((item) => nextParams.append(key, item));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === null || value === undefined) return;
|
||||||
|
|
||||||
|
const normalizedValue =
|
||||||
|
typeof value === "string" ? value.trim() : normalizeScalar(value);
|
||||||
|
const defaultValue = defaults[key];
|
||||||
|
|
||||||
|
if (!normalizedValue.length) return;
|
||||||
|
if (
|
||||||
|
defaultValue !== undefined &&
|
||||||
|
normalizedValue === normalizeScalar(defaultValue)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextParams.set(key, normalizedValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
return nextParams;
|
||||||
|
};
|
||||||
54
src/lib/reportFilters.ts
Normal file
54
src/lib/reportFilters.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { ReportPeriod } from "../api/reports";
|
||||||
|
import type { ReportsFilterDraft } from "../components/reports/ReportsFilterBar";
|
||||||
|
import { readArrayParam, readStringParam, updateQueryParams } from "./queryParams";
|
||||||
|
|
||||||
|
export const DEFAULT_REPORTS_FILTERS: ReportsFilterDraft = {
|
||||||
|
period: "this_month",
|
||||||
|
from_date: "",
|
||||||
|
to_date: "",
|
||||||
|
user: "",
|
||||||
|
client: "",
|
||||||
|
project: "",
|
||||||
|
tags: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readReportsFiltersFromParams = (
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
): ReportsFilterDraft => ({
|
||||||
|
period: readStringParam(
|
||||||
|
searchParams,
|
||||||
|
"period",
|
||||||
|
DEFAULT_REPORTS_FILTERS.period,
|
||||||
|
) as ReportPeriod,
|
||||||
|
from_date: readStringParam(searchParams, "from", ""),
|
||||||
|
to_date: readStringParam(searchParams, "to", ""),
|
||||||
|
user: readStringParam(searchParams, "user", ""),
|
||||||
|
client: readStringParam(searchParams, "client", ""),
|
||||||
|
project: readStringParam(searchParams, "project", ""),
|
||||||
|
tags: readArrayParam(searchParams, "tags"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const writeReportsFiltersToParams = (
|
||||||
|
currentParams: URLSearchParams,
|
||||||
|
filters: ReportsFilterDraft,
|
||||||
|
) =>
|
||||||
|
updateQueryParams(
|
||||||
|
currentParams,
|
||||||
|
{
|
||||||
|
period: filters.period,
|
||||||
|
from: filters.from_date,
|
||||||
|
to: filters.to_date,
|
||||||
|
user: filters.user,
|
||||||
|
client: filters.client,
|
||||||
|
project: filters.project,
|
||||||
|
tags: filters.tags,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: DEFAULT_REPORTS_FILTERS.period,
|
||||||
|
from: "",
|
||||||
|
to: "",
|
||||||
|
user: "",
|
||||||
|
client: "",
|
||||||
|
project: "",
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
import { useSearchParams } from "react-router-dom"
|
||||||
import { Plus, Building2, Pencil, Trash2 } from "lucide-react"
|
import { Plus, Building2, Pencil, Trash2 } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useWorkspace } from "../context/WorkspaceContext"
|
import { useWorkspace } from "../context/WorkspaceContext"
|
||||||
@@ -10,106 +11,113 @@ import {
|
|||||||
canDeleteWorkspaceResource,
|
canDeleteWorkspaceResource,
|
||||||
canWorkspace,
|
canWorkspace,
|
||||||
} from "../lib/permissions"
|
} from "../lib/permissions"
|
||||||
import { type Client } from "../types/client"
|
import { type Client } from "../types/client"
|
||||||
import { getClients } from "../api/clients"
|
import { getClients } from "../api/clients"
|
||||||
import CreateClientModal from "../components/CreateClientModal"
|
import CreateClientModal from "../components/CreateClientModal"
|
||||||
import EditClientModal from "../components/EditClientModal"
|
import EditClientModal from "../components/EditClientModal"
|
||||||
import DeleteClientModal from "../components/DeleteClientModal"
|
import DeleteClientModal from "../components/DeleteClientModal"
|
||||||
import FilterBar from "../components/FilterBar"
|
import FilterBar from "../components/FilterBar"
|
||||||
import { ListPageSkeleton } from "../components/ListPageSkeleton"
|
import { ListPageSkeleton } from "../components/ListPageSkeleton"
|
||||||
import { Button } from "../components/ui/button"
|
import { Button } from "../components/ui/button"
|
||||||
import { Card, CardContent, CardTitle } from "../components/ui/card"
|
import { Card, CardContent, CardTitle } from "../components/ui/card"
|
||||||
import { Pagination } from "../components/Pagination"
|
import { Pagination } from "../components/Pagination"
|
||||||
|
import { readNumberParam, readStringParam, updateQueryParams } from "../lib/queryParams"
|
||||||
|
|
||||||
export default function Clients() {
|
export default function Clients() {
|
||||||
const { activeWorkspace } = useWorkspace()
|
const { activeWorkspace } = useWorkspace()
|
||||||
const { user } = useAppContext()
|
const { user } = useAppContext()
|
||||||
const [clients, setClients] = useState<Client[]>([])
|
const [clients, setClients] = useState<Client[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
// Pagination States
|
const [totalItems, setTotalItems] = useState(0)
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||||
const [totalItems, setTotalItems] = useState(0)
|
const searchQuery = readStringParam(searchParams, "search", "")
|
||||||
const [limit, setLimit] = useState(10)
|
const ordering = readStringParam(searchParams, "ordering", "-created_at")
|
||||||
|
const currentPage = Math.max(1, readNumberParam(searchParams, "page", 1))
|
||||||
// Filter States
|
const limit = Math.max(1, readNumberParam(searchParams, "limit", 10))
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
// Modal States
|
||||||
const [ordering, setOrdering] = useState("-created_at")
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||||
|
const [editClient, setEditClient] = useState<Client | null>(null)
|
||||||
// Modal States
|
const [deleteClient, setDeleteClient] = useState<Client | null>(null)
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
|
||||||
const [editClient, setEditClient] = useState<Client | null>(null)
|
const { t, lang } = useTranslation()
|
||||||
const [deleteClient, setDeleteClient] = useState<Client | null>(null)
|
const isFa = lang === "fa"
|
||||||
|
|
||||||
const { t, lang } = useTranslation()
|
|
||||||
const isFa = lang === "fa"
|
|
||||||
const workspaceRole = activeWorkspace?.my_role
|
const workspaceRole = activeWorkspace?.my_role
|
||||||
const canCreateClient = canWorkspace(workspaceRole, CLIENTS_CREATE)
|
const canCreateClient = canWorkspace(workspaceRole, CLIENTS_CREATE)
|
||||||
const canEditClient = canWorkspace(workspaceRole, CLIENTS_EDIT)
|
const canEditClient = canWorkspace(workspaceRole, CLIENTS_EDIT)
|
||||||
|
|
||||||
const orderingOptions = [
|
const orderingOptions = [
|
||||||
{ value: "-created_at", label: t.ordering?.createdAtDesc || "Newest First" },
|
{ value: "-created_at", label: t.ordering?.createdAtDesc || "Newest First" },
|
||||||
{ value: "created_at", label: t.ordering?.createdAt || "Oldest First" },
|
{ value: "created_at", label: t.ordering?.createdAt || "Oldest First" },
|
||||||
{ value: "name", label: t.ordering?.name || "Name (A-Z)" },
|
{ value: "name", label: t.ordering?.name || "Name (A-Z)" },
|
||||||
{ value: "-name", label: t.ordering?.nameDesc || "Name (Z-A)" },
|
{ value: "-name", label: t.ordering?.nameDesc || "Name (Z-A)" },
|
||||||
{ value: "-updated_at", label: t.ordering?.updatedAtDesc || "Recently Updated" },
|
{ value: "-updated_at", label: t.ordering?.updatedAtDesc || "Recently Updated" },
|
||||||
]
|
]
|
||||||
|
|
||||||
useEffect(() => {
|
// Debounce search input to avoid spamming the API
|
||||||
setCurrentPage(1)
|
useEffect(() => {
|
||||||
}, [debouncedSearch, ordering])
|
const handler = setTimeout(() => {
|
||||||
|
setDebouncedSearch(searchQuery)
|
||||||
// Debounce search input to avoid spamming the API
|
}, 500)
|
||||||
useEffect(() => {
|
return () => clearTimeout(handler)
|
||||||
const handler = setTimeout(() => {
|
}, [searchQuery])
|
||||||
setDebouncedSearch(searchQuery)
|
|
||||||
}, 500)
|
const fetchClientsList = async () => {
|
||||||
return () => clearTimeout(handler)
|
if (!activeWorkspace?.id) {
|
||||||
}, [searchQuery])
|
setIsLoading(false)
|
||||||
|
return
|
||||||
const fetchClientsList = async () => {
|
}
|
||||||
if (!activeWorkspace?.id) {
|
|
||||||
setIsLoading(false)
|
setIsLoading(true)
|
||||||
return
|
try {
|
||||||
}
|
const offset = (currentPage - 1) * limit
|
||||||
|
const data: any = await getClients(activeWorkspace.id, debouncedSearch, ordering, limit, offset)
|
||||||
setIsLoading(true)
|
|
||||||
try {
|
const items = data?.results || (Array.isArray(data) ? data : [])
|
||||||
const offset = (currentPage - 1) * limit
|
const count = data?.count !== undefined ? data.count : items.length
|
||||||
const data: any = await getClients(activeWorkspace.id, debouncedSearch, ordering, limit, offset)
|
|
||||||
|
setClients(items)
|
||||||
const items = data?.results || (Array.isArray(data) ? data : [])
|
setTotalItems(count)
|
||||||
const count = data?.count !== undefined ? data.count : items.length
|
|
||||||
|
|
||||||
setClients(items)
|
|
||||||
setTotalItems(count)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(t.clients.errors.fetchFailed, error)
|
console.error(t.clients.errors.fetchFailed, error)
|
||||||
toast.error(t.clients.errors.fetchFailed)
|
toast.error(t.clients.errors.fetchFailed)
|
||||||
setClients([])
|
setClients([])
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateStr: string | undefined) => {
|
const formatDate = (dateStr: string | undefined) => {
|
||||||
if (!dateStr) return "-"
|
if (!dateStr) return "-"
|
||||||
try {
|
try {
|
||||||
const date = new Date(dateStr)
|
const date = new Date(dateStr)
|
||||||
return new Intl.DateTimeFormat(isFa ? "fa-IR" : "en-US", {
|
return new Intl.DateTimeFormat(isFa ? "fa-IR" : "en-US", {
|
||||||
dateStyle: "long",
|
dateStyle: "long",
|
||||||
timeZone: "Asia/Tehran"
|
timeZone: "Asia/Tehran"
|
||||||
}).format(date)
|
}).format(date)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return dateStr
|
return dateStr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchClientsList()
|
fetchClientsList()
|
||||||
}, [activeWorkspace?.id, debouncedSearch, ordering, currentPage, limit])
|
}, [activeWorkspace?.id, debouncedSearch, ordering, currentPage, limit])
|
||||||
|
|
||||||
|
const updateListParams = (updates: Record<string, string | number | null | undefined>) => {
|
||||||
|
setSearchParams(
|
||||||
|
(current) =>
|
||||||
|
updateQueryParams(current, updates, {
|
||||||
|
search: "",
|
||||||
|
ordering: "-created_at",
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
}),
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!activeWorkspace) {
|
if (!activeWorkspace) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl p-4 md:p-6">
|
<div className="mx-auto max-w-7xl p-4 md:p-6">
|
||||||
@@ -148,9 +156,9 @@ export default function Clients() {
|
|||||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
|
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
|
||||||
<FilterBar
|
<FilterBar
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
setSearchQuery={setSearchQuery}
|
setSearchQuery={(value) => updateListParams({ search: value, page: 1 })}
|
||||||
ordering={ordering}
|
ordering={ordering}
|
||||||
setOrdering={setOrdering}
|
setOrdering={(value) => updateListParams({ ordering: value, page: 1 })}
|
||||||
orderingOptions={orderingOptions}
|
orderingOptions={orderingOptions}
|
||||||
searchPlaceholder={t.clients.searchPlaceholder}
|
searchPlaceholder={t.clients.searchPlaceholder}
|
||||||
/>
|
/>
|
||||||
@@ -161,7 +169,7 @@ export default function Clients() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-1 flex-col gap-6">
|
<div className="flex flex-1 flex-col gap-6">
|
||||||
{clients.length === 0 ? (
|
{clients.length === 0 ? (
|
||||||
<div className="flex flex-1 rounded-3xl border-2 border-dashed border-slate-200 bg-white p-12 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
<div className="flex flex-col flex-1 rounded-3xl border-2 border-dashed border-slate-200 bg-white p-12 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
<Building2 className="mx-auto mb-3 h-12 w-12 text-slate-300 dark:text-slate-700" />
|
<Building2 className="mx-auto mb-3 h-12 w-12 text-slate-300 dark:text-slate-700" />
|
||||||
<h3 className="text-lg font-medium text-slate-900 dark:text-white">{t.clients.noClients}</h3>
|
<h3 className="text-lg font-medium text-slate-900 dark:text-white">{t.clients.noClients}</h3>
|
||||||
<p className="mt-1 text-slate-500 dark:text-slate-400">
|
<p className="mt-1 text-slate-500 dark:text-slate-400">
|
||||||
@@ -238,8 +246,8 @@ export default function Clients() {
|
|||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalCount={totalItems}
|
totalCount={totalItems}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
onPageChange={setCurrentPage}
|
onPageChange={(page) => updateListParams({ page })}
|
||||||
onLimitChange={setLimit}
|
onLimitChange={(pageLimit) => updateListParams({ limit: pageLimit, page: 1 })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -248,30 +256,30 @@ export default function Clients() {
|
|||||||
|
|
||||||
{canCreateClient && (
|
{canCreateClient && (
|
||||||
<CreateClientModal
|
<CreateClientModal
|
||||||
isOpen={isCreateModalOpen}
|
isOpen={isCreateModalOpen}
|
||||||
onClose={() => setIsCreateModalOpen(false)}
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
onSuccess={fetchClientsList}
|
onSuccess={fetchClientsList}
|
||||||
workspaceId={activeWorkspace.id}
|
workspaceId={activeWorkspace.id}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canEditClient && (
|
{canEditClient && (
|
||||||
<EditClientModal
|
<EditClientModal
|
||||||
isOpen={!!editClient}
|
isOpen={!!editClient}
|
||||||
onClose={() => setEditClient(null)}
|
onClose={() => setEditClient(null)}
|
||||||
onSuccess={fetchClientsList}
|
onSuccess={fetchClientsList}
|
||||||
client={editClient}
|
client={editClient}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!!deleteClient && (
|
{!!deleteClient && (
|
||||||
<DeleteClientModal
|
<DeleteClientModal
|
||||||
isOpen={!!deleteClient}
|
isOpen={!!deleteClient}
|
||||||
onClose={() => setDeleteClient(null)}
|
onClose={() => setDeleteClient(null)}
|
||||||
onSuccess={fetchClientsList}
|
onSuccess={fetchClientsList}
|
||||||
client={deleteClient}
|
client={deleteClient}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { History, ShieldCheck, SlidersHorizontal } from "lucide-react";
|
import { History, ShieldCheck, SlidersHorizontal } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ import { LogsFeed } from "../components/logs/LogsFeed";
|
|||||||
import { LogsFilterBar, type LogsFilterDraft } from "../components/logs/LogsFilterBar";
|
import { LogsFilterBar, type LogsFilterDraft } from "../components/logs/LogsFilterBar";
|
||||||
import { useWorkspace } from "../context/WorkspaceContext";
|
import { useWorkspace } from "../context/WorkspaceContext";
|
||||||
import { useTranslation } from "../hooks/useTranslation";
|
import { useTranslation } from "../hooks/useTranslation";
|
||||||
|
import { readStringParam, updateQueryParams } from "../lib/queryParams";
|
||||||
import { canWorkspace, WORKSPACE_LOGS_VIEW } from "../lib/permissions";
|
import { canWorkspace, WORKSPACE_LOGS_VIEW } from "../lib/permissions";
|
||||||
|
|
||||||
const DEFAULT_FILTERS: LogsFilterDraft = {
|
const DEFAULT_FILTERS: LogsFilterDraft = {
|
||||||
@@ -26,12 +28,22 @@ const DEFAULT_FILTERS: LogsFilterDraft = {
|
|||||||
ordering: "-timestamp",
|
ordering: "-timestamp",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_QUERY_FILTERS: Record<string, string> = {
|
||||||
|
search: DEFAULT_FILTERS.search,
|
||||||
|
section: DEFAULT_FILTERS.section,
|
||||||
|
event: DEFAULT_FILTERS.event,
|
||||||
|
actor: DEFAULT_FILTERS.actor,
|
||||||
|
from: DEFAULT_FILTERS.from,
|
||||||
|
to: DEFAULT_FILTERS.to,
|
||||||
|
ordering: DEFAULT_FILTERS.ordering,
|
||||||
|
};
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
export default function Logs() {
|
export default function Logs() {
|
||||||
const { t, lang } = useTranslation();
|
const { t, lang } = useTranslation();
|
||||||
const { activeWorkspace } = useWorkspace();
|
const { activeWorkspace } = useWorkspace();
|
||||||
const [filters, setFilters] = useState<LogsFilterDraft>(DEFAULT_FILTERS);
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [memberships, setMemberships] = useState<WorkspaceMembership[]>([]);
|
const [memberships, setMemberships] = useState<WorkspaceMembership[]>([]);
|
||||||
const [logs, setLogs] = useState<WorkspaceLogItem[]>([]);
|
const [logs, setLogs] = useState<WorkspaceLogItem[]>([]);
|
||||||
const [totalLogs, setTotalLogs] = useState(0);
|
const [totalLogs, setTotalLogs] = useState(0);
|
||||||
@@ -45,9 +57,20 @@ export default function Logs() {
|
|||||||
const workspaceRole = activeWorkspace?.my_role;
|
const workspaceRole = activeWorkspace?.my_role;
|
||||||
const canViewLogs = canWorkspace(workspaceRole, WORKSPACE_LOGS_VIEW);
|
const canViewLogs = canWorkspace(workspaceRole, WORKSPACE_LOGS_VIEW);
|
||||||
const isWorkspaceRoleResolved = Boolean(workspaceRole);
|
const isWorkspaceRoleResolved = Boolean(workspaceRole);
|
||||||
|
const filters = useMemo<LogsFilterDraft>(
|
||||||
|
() => ({
|
||||||
|
search: readStringParam(searchParams, "search", DEFAULT_FILTERS.search),
|
||||||
|
section: readStringParam(searchParams, "section", DEFAULT_FILTERS.section) as LogsFilterDraft["section"],
|
||||||
|
event: readStringParam(searchParams, "event", DEFAULT_FILTERS.event) as LogsFilterDraft["event"],
|
||||||
|
actor: readStringParam(searchParams, "actor", DEFAULT_FILTERS.actor),
|
||||||
|
from: readStringParam(searchParams, "from", DEFAULT_FILTERS.from),
|
||||||
|
to: readStringParam(searchParams, "to", DEFAULT_FILTERS.to),
|
||||||
|
ordering: readStringParam(searchParams, "ordering", DEFAULT_FILTERS.ordering) as LogsFilterDraft["ordering"],
|
||||||
|
}),
|
||||||
|
[searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFilters(DEFAULT_FILTERS);
|
|
||||||
setLogs([]);
|
setLogs([]);
|
||||||
setTotalLogs(0);
|
setTotalLogs(0);
|
||||||
setSelectedLogId(null);
|
setSelectedLogId(null);
|
||||||
@@ -284,7 +307,25 @@ export default function Logs() {
|
|||||||
users={memberships}
|
users={memberships}
|
||||||
isLoadingUsers={isLoadingUsers}
|
isLoadingUsers={isLoadingUsers}
|
||||||
canSelectUsers={canViewLogs}
|
canSelectUsers={canViewLogs}
|
||||||
onApply={setFilters}
|
onApply={(nextFilters) =>
|
||||||
|
setSearchParams(
|
||||||
|
(current) =>
|
||||||
|
updateQueryParams(
|
||||||
|
current,
|
||||||
|
{
|
||||||
|
search: nextFilters.search,
|
||||||
|
section: nextFilters.section,
|
||||||
|
event: nextFilters.event,
|
||||||
|
actor: nextFilters.actor,
|
||||||
|
from: nextFilters.from,
|
||||||
|
to: nextFilters.to,
|
||||||
|
ordering: nextFilters.ordering,
|
||||||
|
},
|
||||||
|
DEFAULT_QUERY_FILTERS,
|
||||||
|
),
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LogsFeed
|
<LogsFeed
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { useTranslation } from "../hooks/useTranslation";
|
import { useTranslation } from "../hooks/useTranslation";
|
||||||
import { getProjects, deleteProject, type Project } from "../api/projects";
|
import { getProjects, deleteProject, type Project } from "../api/projects";
|
||||||
import { getClients } from "../api/clients";
|
import { getClients } from "../api/clients";
|
||||||
@@ -16,14 +17,21 @@ import { Card, CardContent, CardTitle } from "../components/ui/card";
|
|||||||
import { Modal } from "../components/Modal";
|
import { Modal } from "../components/Modal";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
import {
|
import {
|
||||||
PROJECTS_ARCHIVE,
|
PROJECTS_ARCHIVE,
|
||||||
PROJECTS_CREATE,
|
PROJECTS_CREATE,
|
||||||
PROJECTS_EDIT,
|
PROJECTS_EDIT,
|
||||||
canDeleteWorkspaceResource,
|
canDeleteWorkspaceResource,
|
||||||
canWorkspace,
|
canWorkspace,
|
||||||
} from "../lib/permissions";
|
} from "../lib/permissions";
|
||||||
|
import {
|
||||||
|
readArrayParam,
|
||||||
|
readBooleanParam,
|
||||||
|
readNumberParam,
|
||||||
|
readStringParam,
|
||||||
|
updateQueryParams,
|
||||||
|
} from "../lib/queryParams";
|
||||||
|
|
||||||
export const Projects: React.FC = () => {
|
export const Projects: React.FC = () => {
|
||||||
const { t, lang } = useTranslation();
|
const { t, lang } = useTranslation();
|
||||||
const { user } = useAppContext();
|
const { user } = useAppContext();
|
||||||
@@ -32,24 +40,44 @@ export const Projects: React.FC = () => {
|
|||||||
const canCreateProject = canWorkspace(workspaceRole, PROJECTS_CREATE);
|
const canCreateProject = canWorkspace(workspaceRole, PROJECTS_CREATE);
|
||||||
const canEditProject = canWorkspace(workspaceRole, PROJECTS_EDIT);
|
const canEditProject = canWorkspace(workspaceRole, PROJECTS_EDIT);
|
||||||
const canArchiveProject = canWorkspace(workspaceRole, PROJECTS_ARCHIVE);
|
const canArchiveProject = canWorkspace(workspaceRole, PROJECTS_ARCHIVE);
|
||||||
|
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [clients, setClients] = useState<{ id: string; name: string }[]>([]);
|
const [clients, setClients] = useState<{ id: string; name: string }[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [ordering, setOrdering] = useState("-created_at");
|
const search = useMemo(() => readStringParam(searchParams, "search", ""), [searchParams]);
|
||||||
const [isArchived, setIsArchived] = useState(false);
|
const ordering = useMemo(
|
||||||
const [selectedClientIds, setSelectedClientIds] = useState<string[]>([]);
|
() => readStringParam(searchParams, "ordering", "-created_at"),
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
[searchParams],
|
||||||
const [limit, setLimit] = useState(10);
|
);
|
||||||
|
const isArchived = useMemo(
|
||||||
|
() => readBooleanParam(searchParams, "archived", false),
|
||||||
|
[searchParams],
|
||||||
|
);
|
||||||
|
const selectedClientIds = useMemo(
|
||||||
|
() => readArrayParam(searchParams, "clients"),
|
||||||
|
[searchParams],
|
||||||
|
);
|
||||||
|
const selectedClientIdsKey = useMemo(
|
||||||
|
() => selectedClientIds.join(","),
|
||||||
|
[selectedClientIds],
|
||||||
|
);
|
||||||
|
const currentPage = useMemo(
|
||||||
|
() => Math.max(1, readNumberParam(searchParams, "page", 1)),
|
||||||
|
[searchParams],
|
||||||
|
);
|
||||||
|
const limit = useMemo(
|
||||||
|
() => Math.max(1, readNumberParam(searchParams, "limit", 10)),
|
||||||
|
[searchParams],
|
||||||
|
);
|
||||||
const [totalItems, setTotalItems] = useState(0);
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
|
|
||||||
const [deleteModal, setDeleteModal] = useState<{isOpen: boolean; project: Project | null}>({isOpen: false, project: null});
|
const [deleteModal, setDeleteModal] = useState<{isOpen: boolean; project: Project | null}>({isOpen: false, project: null});
|
||||||
const [deleteInput, setDeleteInput] = useState('');
|
const [deleteInput, setDeleteInput] = useState('');
|
||||||
|
|
||||||
const orderingOptions = [
|
const orderingOptions = [
|
||||||
{ value: '-created_at', label: t.ordering?.createdAtDesc || 'Newest First' },
|
{ value: '-created_at', label: t.ordering?.createdAtDesc || 'Newest First' },
|
||||||
{ value: 'created_at', label: t.ordering?.createdAt || 'Oldest 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)' },
|
{ value: '-name', label: t.ordering?.nameDesc || 'Name (Z-A)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentPage(1);
|
|
||||||
}, [search, ordering, isArchived, selectedClientIds]);
|
|
||||||
|
|
||||||
const fetchProjectList = async () => {
|
const fetchProjectList = async () => {
|
||||||
if (!activeWorkspace) return;
|
if (!activeWorkspace) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -74,8 +98,8 @@ export const Projects: React.FC = () => {
|
|||||||
is_archived: isArchived,
|
is_archived: isArchived,
|
||||||
ordering
|
ordering
|
||||||
});
|
});
|
||||||
const items = data?.results || (Array.isArray(data) ? data : [])
|
const items = data?.results || (Array.isArray(data) ? data : [])
|
||||||
const count = data?.count !== undefined ? data.count : items.length
|
const count = data?.count !== undefined ? data.count : items.length
|
||||||
setProjects(items);
|
setProjects(items);
|
||||||
setTotalItems(count)
|
setTotalItems(count)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -106,52 +130,52 @@ export const Projects: React.FC = () => {
|
|||||||
void fetchProjectList();
|
void fetchProjectList();
|
||||||
}, 300);
|
}, 300);
|
||||||
return () => clearTimeout(delayDebounceFn);
|
return () => clearTimeout(delayDebounceFn);
|
||||||
}, [activeWorkspace, currentPage, limit, search, isArchived, ordering, selectedClientIds]);
|
}, [activeWorkspace?.id, currentPage, limit, search, isArchived, ordering, selectedClientIdsKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleCreated = () => void fetchProjectList();
|
const handleCreated = () => void fetchProjectList();
|
||||||
const handleUpdated = () => void fetchProjectList();
|
const handleUpdated = () => void fetchProjectList();
|
||||||
|
|
||||||
window.addEventListener("project_created", handleCreated);
|
window.addEventListener("project_created", handleCreated);
|
||||||
window.addEventListener("project_updated", handleUpdated);
|
window.addEventListener("project_updated", handleUpdated);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("project_created", handleCreated);
|
window.removeEventListener("project_created", handleCreated);
|
||||||
window.removeEventListener("project_updated", handleUpdated);
|
window.removeEventListener("project_updated", handleUpdated);
|
||||||
};
|
};
|
||||||
}, [activeWorkspace, currentPage, limit, search, isArchived, ordering]);
|
}, [activeWorkspace?.id, currentPage, limit, search, isArchived, ordering, selectedClientIdsKey]);
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const confirmDelete = async () => {
|
||||||
if (!deleteModal.project) return;
|
if (!deleteModal.project) return;
|
||||||
try {
|
try {
|
||||||
const deletedId = deleteModal.project.id;
|
const deletedId = deleteModal.project.id;
|
||||||
await deleteProject(deletedId);
|
await deleteProject(deletedId);
|
||||||
|
|
||||||
fetchProjectList();
|
fetchProjectList();
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('project_deleted', {
|
window.dispatchEvent(new CustomEvent('project_deleted', {
|
||||||
detail: { id: deletedId }
|
detail: { id: deletedId }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
toast.success(t.projects?.deleteSuccess || 'Project deleted successfully');
|
toast.success(t.projects?.deleteSuccess || 'Project deleted successfully');
|
||||||
setDeleteModal({ isOpen: false, project: null });
|
setDeleteModal({ isOpen: false, project: null });
|
||||||
setDeleteInput('');
|
setDeleteInput('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t.projects?.deleteError || 'Failed to delete project');
|
toast.error(t.projects?.deleteError || 'Failed to delete project');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr: string | undefined) => {
|
const formatDate = (dateStr: string | undefined) => {
|
||||||
if (!dateStr) return "-"
|
if (!dateStr) return "-"
|
||||||
try {
|
try {
|
||||||
const date = new Date(dateStr)
|
const date = new Date(dateStr)
|
||||||
return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR" : "en-US", {
|
return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR" : "en-US", {
|
||||||
dateStyle: "long",
|
dateStyle: "long",
|
||||||
timeZone: "Asia/Tehran",
|
timeZone: "Asia/Tehran",
|
||||||
}).format(date)
|
}).format(date)
|
||||||
} catch {
|
} catch {
|
||||||
return dateStr
|
return dateStr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedClients = useMemo(() => {
|
const sortedClients = useMemo(() => {
|
||||||
@@ -159,14 +183,29 @@ export const Projects: React.FC = () => {
|
|||||||
const selected = clients.filter((client) => selectedClientIds.includes(client.id));
|
const selected = clients.filter((client) => selectedClientIds.includes(client.id));
|
||||||
const unselected = clients.filter((client) => !selectedClientIds.includes(client.id));
|
const unselected = clients.filter((client) => !selectedClientIds.includes(client.id));
|
||||||
return [...selected, ...unselected];
|
return [...selected, ...unselected];
|
||||||
}, [clients, selectedClientIds]);
|
}, [clients, selectedClientIdsKey]);
|
||||||
|
|
||||||
const toggleClientFilter = (clientId: string) => {
|
const toggleClientFilter = (clientId: string) => {
|
||||||
setCurrentPage(1);
|
const nextClientIds = selectedClientIds.includes(clientId)
|
||||||
setSelectedClientIds((current) =>
|
? selectedClientIds.filter((id) => id !== clientId)
|
||||||
current.includes(clientId)
|
: [...selectedClientIds, clientId];
|
||||||
? current.filter((id) => id !== clientId)
|
|
||||||
: [...current, clientId],
|
updateListParams({ clients: nextClientIds, page: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateListParams = (
|
||||||
|
updates: Record<string, string | number | boolean | null | undefined | string[]>,
|
||||||
|
) => {
|
||||||
|
setSearchParams(
|
||||||
|
(current) =>
|
||||||
|
updateQueryParams(current, updates, {
|
||||||
|
search: "",
|
||||||
|
ordering: "-created_at",
|
||||||
|
archived: false,
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
}),
|
||||||
|
{ replace: true },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -194,7 +233,7 @@ export const Projects: React.FC = () => {
|
|||||||
{canArchiveProject && (
|
{canArchiveProject && (
|
||||||
<Button
|
<Button
|
||||||
variant={isArchived ? "default" : "secondary"}
|
variant={isArchived ? "default" : "secondary"}
|
||||||
onClick={() => setIsArchived(!isArchived)}
|
onClick={() => updateListParams({ archived: !isArchived, page: 1 })}
|
||||||
className="flex-1 gap-2 shadow-sm sm:flex-none"
|
className="flex-1 gap-2 shadow-sm sm:flex-none"
|
||||||
>
|
>
|
||||||
<Archive className="h-4 w-4" />
|
<Archive className="h-4 w-4" />
|
||||||
@@ -218,9 +257,9 @@ export const Projects: React.FC = () => {
|
|||||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
|
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
|
||||||
<FilterBar
|
<FilterBar
|
||||||
searchQuery={search}
|
searchQuery={search}
|
||||||
setSearchQuery={setSearch}
|
setSearchQuery={(value) => updateListParams({ search: value, page: 1 })}
|
||||||
ordering={ordering}
|
ordering={ordering}
|
||||||
setOrdering={setOrdering}
|
setOrdering={(value) => updateListParams({ ordering: value, page: 1 })}
|
||||||
orderingOptions={orderingOptions}
|
orderingOptions={orderingOptions}
|
||||||
searchPlaceholder={t.projects?.searchPlaceholder || 'Search projects...'}
|
searchPlaceholder={t.projects?.searchPlaceholder || 'Search projects...'}
|
||||||
/>
|
/>
|
||||||
@@ -233,8 +272,7 @@ export const Projects: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurrentPage(1);
|
updateListParams({ clients: [], page: 1 });
|
||||||
setSelectedClientIds([]);
|
|
||||||
}}
|
}}
|
||||||
className="text-xs font-medium text-slate-500 transition hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
className="text-xs font-medium text-slate-500 transition hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
||||||
>
|
>
|
||||||
@@ -291,9 +329,10 @@ export const Projects: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-1 flex-col gap-6">
|
<div className="flex flex-1 flex-col gap-6">
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
<div className="flex flex-1 rounded-3xl border-2 border-dashed border-slate-200 bg-white p-12 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
<div className="flex flex-col flex-1 rounded-3xl border-2 border-dashed border-slate-200 bg-white p-12 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
<Building2 className="mx-auto mb-3 h-12 w-12 text-slate-300 dark:text-slate-700" />
|
<Building2 className="mx-auto mb-3 h-12 w-12 text-slate-300 dark:text-slate-700" />
|
||||||
<p className="font-medium text-slate-500 dark:text-slate-400">{t.projects?.emptyState || 'No projects found'}</p>
|
<h3 className="text-lg font-medium text-slate-900 dark:text-white">{t.projects?.emptyState || 'No projects found'}</h3>
|
||||||
|
<p className="mt-1 text-slate-500 dark:text-slate-400">{t.projects?.noProjectsSearch}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 2xl:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 2xl:grid-cols-3">
|
||||||
@@ -374,8 +413,8 @@ export const Projects: React.FC = () => {
|
|||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalCount={totalItems}
|
totalCount={totalItems}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
onPageChange={setCurrentPage}
|
onPageChange={(page) => updateListParams({ page })}
|
||||||
onLimitChange={setLimit}
|
onLimitChange={(pageLimit) => updateListParams({ limit: pageLimit, page: 1 })}
|
||||||
pageSizeOptions={[10, 20, 50]}
|
pageSizeOptions={[10, 20, 50]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -384,68 +423,68 @@ export const Projects: React.FC = () => {
|
|||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
{canCreateProject && isCreateModalOpen && (
|
{canCreateProject && isCreateModalOpen && (
|
||||||
<ProjectCreateModal
|
<ProjectCreateModal
|
||||||
isOpen={isCreateModalOpen}
|
isOpen={isCreateModalOpen}
|
||||||
onClose={() => setIsCreateModalOpen(false)}
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canEditProject && editingProject && (
|
{canEditProject && editingProject && (
|
||||||
<ProjectEditModal
|
<ProjectEditModal
|
||||||
project={editingProject}
|
project={editingProject}
|
||||||
isOpen={!!editingProject}
|
isOpen={!!editingProject}
|
||||||
onClose={() => setEditingProject(null)}
|
onClose={() => setEditingProject(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{deleteModal.project && (
|
{deleteModal.project && (
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={deleteModal.isOpen}
|
isOpen={deleteModal.isOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setDeleteModal({ isOpen: false, project: null });
|
setDeleteModal({ isOpen: false, project: null });
|
||||||
setDeleteInput('');
|
setDeleteInput('');
|
||||||
}}
|
}}
|
||||||
title={t.projects?.deleteTitle || 'Delete Project'}
|
title={t.projects?.deleteTitle || 'Delete Project'}
|
||||||
maxWidth="max-w-md"
|
maxWidth="max-w-md"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDeleteModal({ isOpen: false, project: null });
|
setDeleteModal({ isOpen: false, project: null });
|
||||||
setDeleteInput('');
|
setDeleteInput('');
|
||||||
}}
|
}}
|
||||||
className="rounded-xl font-semibold"
|
className="rounded-xl font-semibold"
|
||||||
>
|
>
|
||||||
{t.actions?.cancel || 'Cancel'}
|
{t.actions?.cancel || 'Cancel'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={deleteInput !== deleteModal.project.name}
|
disabled={deleteInput !== deleteModal.project.name}
|
||||||
onClick={confirmDelete}
|
onClick={confirmDelete}
|
||||||
className="rounded-xl font-semibold"
|
className="rounded-xl font-semibold"
|
||||||
>
|
>
|
||||||
{t.actions?.delete || 'Delete'}
|
{t.actions?.delete || 'Delete'}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<p className="text-slate-600 dark:text-slate-400 text-sm leading-relaxed">
|
<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>
|
{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>
|
</p>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={deleteInput}
|
value={deleteInput}
|
||||||
onChange={(e) => setDeleteInput(e.target.value)}
|
onChange={(e) => setDeleteInput(e.target.value)}
|
||||||
placeholder={deleteModal.project.name}
|
placeholder={deleteModal.project.name}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { BarChart3, Table2 } from "lucide-react";
|
import { BarChart3, Table2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -21,6 +22,12 @@ import { ReportsFilterBar, type ReportsFilterDraft } from "../components/reports
|
|||||||
import { ReportsTablePanel } from "../components/reports/ReportsTablePanel";
|
import { ReportsTablePanel } from "../components/reports/ReportsTablePanel";
|
||||||
import { useWorkspace } from "../context/WorkspaceContext";
|
import { useWorkspace } from "../context/WorkspaceContext";
|
||||||
import { useTranslation } from "../hooks/useTranslation";
|
import { useTranslation } from "../hooks/useTranslation";
|
||||||
|
import {
|
||||||
|
DEFAULT_REPORTS_FILTERS,
|
||||||
|
readReportsFiltersFromParams,
|
||||||
|
writeReportsFiltersToParams,
|
||||||
|
} from "../lib/reportFilters";
|
||||||
|
import { readStringParam, updateQueryParams } from "../lib/queryParams";
|
||||||
import { canWorkspace, WORKSPACE_MEMBERS_VIEW } from "../lib/permissions";
|
import { canWorkspace, WORKSPACE_MEMBERS_VIEW } from "../lib/permissions";
|
||||||
|
|
||||||
type Tab = "chart" | "table";
|
type Tab = "chart" | "table";
|
||||||
@@ -86,7 +93,8 @@ const getCurrentLanguageAwareMonthRange = (lang: "en" | "fa") => {
|
|||||||
export default function Reports() {
|
export default function Reports() {
|
||||||
const { t, lang } = useTranslation();
|
const { t, lang } = useTranslation();
|
||||||
const { activeWorkspace } = useWorkspace();
|
const { activeWorkspace } = useWorkspace();
|
||||||
const [tab, setTab] = useState<Tab>("chart");
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const tab = (readStringParam(searchParams, "tab", "chart") as Tab);
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [clients, setClients] = useState<{ id: string; name: string }[]>([]);
|
const [clients, setClients] = useState<{ id: string; name: string }[]>([]);
|
||||||
const [tags, setTags] = useState<Tag[]>([]);
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
@@ -105,16 +113,10 @@ export default function Reports() {
|
|||||||
const canSelectUsers = canWorkspace(activeWorkspace?.my_role, WORKSPACE_MEMBERS_VIEW);
|
const canSelectUsers = canWorkspace(activeWorkspace?.my_role, WORKSPACE_MEMBERS_VIEW);
|
||||||
const isWorkspaceRoleResolved = Boolean(activeWorkspace?.my_role);
|
const isWorkspaceRoleResolved = Boolean(activeWorkspace?.my_role);
|
||||||
const showUserFilterLoading = !isWorkspaceRoleResolved || (canSelectUsers && isLoadingUsers);
|
const showUserFilterLoading = !isWorkspaceRoleResolved || (canSelectUsers && isLoadingUsers);
|
||||||
|
const filters = useMemo<ReportsFilterDraft>(
|
||||||
const [filters, setFilters] = useState<ReportsFilterDraft>({
|
() => readReportsFiltersFromParams(searchParams),
|
||||||
period: "this_month",
|
[searchParams],
|
||||||
from_date: "",
|
);
|
||||||
to_date: "",
|
|
||||||
user: "",
|
|
||||||
client: "",
|
|
||||||
project: "",
|
|
||||||
tags: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeWorkspace?.id) return;
|
if (!activeWorkspace?.id) return;
|
||||||
@@ -177,6 +179,7 @@ export default function Reports() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const apiFilters = useMemo<ReportFilters | null>(() => buildApiFilters(filters), [activeWorkspace?.id, canSelectUsers, filters, lang]);
|
const apiFilters = useMemo<ReportFilters | null>(() => buildApiFilters(filters), [activeWorkspace?.id, canSelectUsers, filters, lang]);
|
||||||
|
const apiFiltersKey = apiFilters ? JSON.stringify(apiFilters) : "";
|
||||||
|
|
||||||
const runReportLoad = async (nextFilters: ReportFilters) => {
|
const runReportLoad = async (nextFilters: ReportFilters) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -199,8 +202,7 @@ export default function Reports() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!apiFilters) return;
|
if (!apiFilters) return;
|
||||||
void runReportLoad(apiFilters);
|
void runReportLoad(apiFilters);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [apiFilters, apiFiltersKey]);
|
||||||
}, [apiFilters?.workspace]);
|
|
||||||
|
|
||||||
const handleToggleDay = async (day: string) => {
|
const handleToggleDay = async (day: string) => {
|
||||||
if (!apiFilters) return;
|
if (!apiFilters) return;
|
||||||
@@ -283,7 +285,12 @@ export default function Reports() {
|
|||||||
<div className="grid w-full grid-cols-2 rounded-2xl border border-slate-200 bg-slate-50 p-1 dark:border-slate-800 dark:bg-slate-950 lg:w-auto">
|
<div className="grid w-full grid-cols-2 rounded-2xl border border-slate-200 bg-slate-50 p-1 dark:border-slate-800 dark:bg-slate-950 lg:w-auto">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTab("chart")}
|
onClick={() =>
|
||||||
|
setSearchParams(
|
||||||
|
(current) => updateQueryParams(current, { tab: "chart" }, { tab: "chart" }),
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
className={`inline-flex h-11 items-center justify-center gap-2 rounded-xl px-4 text-sm font-medium transition ${
|
className={`inline-flex h-11 items-center justify-center gap-2 rounded-xl px-4 text-sm font-medium transition ${
|
||||||
tab === "chart"
|
tab === "chart"
|
||||||
? "bg-white text-slate-900 shadow-sm dark:bg-slate-900 dark:text-white"
|
? "bg-white text-slate-900 shadow-sm dark:bg-slate-900 dark:text-white"
|
||||||
@@ -295,7 +302,12 @@ export default function Reports() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTab("table")}
|
onClick={() =>
|
||||||
|
setSearchParams(
|
||||||
|
(current) => updateQueryParams(current, { tab: "table" }, { tab: "chart" }),
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
className={`inline-flex h-11 items-center justify-center gap-2 rounded-xl px-4 text-sm font-medium transition ${
|
className={`inline-flex h-11 items-center justify-center gap-2 rounded-xl px-4 text-sm font-medium transition ${
|
||||||
tab === "table"
|
tab === "table"
|
||||||
? "bg-white text-slate-900 shadow-sm dark:bg-slate-900 dark:text-white"
|
? "bg-white text-slate-900 shadow-sm dark:bg-slate-900 dark:text-white"
|
||||||
@@ -311,12 +323,12 @@ export default function Reports() {
|
|||||||
|
|
||||||
<ReportsFilterBar
|
<ReportsFilterBar
|
||||||
value={filters}
|
value={filters}
|
||||||
onApply={(draft) => {
|
onApply={(draft) =>
|
||||||
setFilters(draft);
|
setSearchParams(
|
||||||
const nextFilters = buildApiFilters(draft);
|
(current) => writeReportsFiltersToParams(current, draft),
|
||||||
if (!nextFilters) return;
|
{ replace: true },
|
||||||
void runReportLoad(nextFilters);
|
)
|
||||||
}}
|
}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
clients={clients}
|
clients={clients}
|
||||||
tags={tags}
|
tags={tags}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { Edit2, Plus, Tag as TagIcon, Trash2 } from "lucide-react";
|
import { Edit2, Plus, Tag as TagIcon, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ import { Pagination } from "../components/Pagination";
|
|||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Card, CardContent, CardTitle } from "../components/ui/card";
|
import { Card, CardContent, CardTitle } from "../components/ui/card";
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
|
import { readNumberParam, readStringParam, updateQueryParams } from "../lib/queryParams";
|
||||||
|
|
||||||
const DEFAULT_COLOR = "#3B82F6";
|
const DEFAULT_COLOR = "#3B82F6";
|
||||||
|
|
||||||
@@ -24,14 +26,15 @@ export default function Tags() {
|
|||||||
const workspaceRole = activeWorkspace?.my_role;
|
const workspaceRole = activeWorkspace?.my_role;
|
||||||
const canCreateTag = canWorkspace(workspaceRole, TAGS_CREATE);
|
const canCreateTag = canWorkspace(workspaceRole, TAGS_CREATE);
|
||||||
const canEditTag = canWorkspace(workspaceRole, TAGS_EDIT);
|
const canEditTag = canWorkspace(workspaceRole, TAGS_EDIT);
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const [tags, setTags] = useState<Tag[]>([]);
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [ordering, setOrdering] = useState("-updated_at");
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [totalItems, setTotalItems] = useState(0);
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
const [limit, setLimit] = useState(10);
|
const searchQuery = readStringParam(searchParams, "search", "");
|
||||||
|
const ordering = readStringParam(searchParams, "ordering", "-updated_at");
|
||||||
|
const currentPage = Math.max(1, readNumberParam(searchParams, "page", 1));
|
||||||
|
const limit = Math.max(1, readNumberParam(searchParams, "limit", 10));
|
||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
||||||
@@ -48,10 +51,6 @@ export default function Tags() {
|
|||||||
{ value: "-name", label: t.ordering?.nameDesc || "Name (Z-A)" },
|
{ value: "-name", label: t.ordering?.nameDesc || "Name (Z-A)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentPage(1);
|
|
||||||
}, [searchQuery, ordering]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeWorkspace?.id) return;
|
if (!activeWorkspace?.id) return;
|
||||||
|
|
||||||
@@ -140,6 +139,19 @@ export default function Tags() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateListParams = (updates: Record<string, string | number | null | undefined>) => {
|
||||||
|
setSearchParams(
|
||||||
|
(current) =>
|
||||||
|
updateQueryParams(current, updates, {
|
||||||
|
search: "",
|
||||||
|
ordering: "-updated_at",
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
}),
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (!activeWorkspace) {
|
if (!activeWorkspace) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl p-4 md:p-6">
|
<div className="mx-auto max-w-7xl p-4 md:p-6">
|
||||||
@@ -172,9 +184,9 @@ export default function Tags() {
|
|||||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
|
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
|
||||||
<FilterBar
|
<FilterBar
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
setSearchQuery={setSearchQuery}
|
setSearchQuery={(value) => updateListParams({ search: value, page: 1 })}
|
||||||
ordering={ordering}
|
ordering={ordering}
|
||||||
setOrdering={setOrdering}
|
setOrdering={(value) => updateListParams({ ordering: value, page: 1 })}
|
||||||
orderingOptions={orderingOptions}
|
orderingOptions={orderingOptions}
|
||||||
searchPlaceholder={t.tags?.searchPlaceholder || "Search tags..."}
|
searchPlaceholder={t.tags?.searchPlaceholder || "Search tags..."}
|
||||||
/>
|
/>
|
||||||
@@ -238,9 +250,12 @@ export default function Tags() {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{tags.length === 0 && (
|
{tags.length === 0 && (
|
||||||
<div className="col-span-full flex flex-1 flex-col items-center justify-center rounded-3xl border-2 border-dashed border-slate-200 bg-white py-16 text-slate-500 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
|
<div className="col-span-full flex flex-col flex-1 rounded-3xl border-2 border-dashed border-slate-200 bg-white p-12 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
<TagIcon className="w-10 h-10 mb-3" />
|
<TagIcon className="mx-auto mb-3 h-12 w-12 text-slate-300 dark:text-slate-700" />
|
||||||
<p className="font-medium">{t.tags?.emptyState || "No tags found"}</p>
|
<h3 className="text-lg font-medium text-slate-900 dark:text-white">{t.tags?.emptyState || "No tags found"}</h3>
|
||||||
|
<p className="mt-1 text-slate-500 dark:text-slate-400">
|
||||||
|
{searchQuery ? t.tags?.noTagsSearch : t.tags?.emptyState}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -249,8 +264,8 @@ export default function Tags() {
|
|||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalCount={totalItems}
|
totalCount={totalItems}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
onPageChange={setCurrentPage}
|
onPageChange={(page) => updateListParams({ page })}
|
||||||
onLimitChange={setLimit}
|
onLimitChange={(pageLimit) => updateListParams({ limit: pageLimit, page: 1 })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { CalendarDays, Check, ChevronDown, Clock3, DollarSign, MoreVertical, Pencil, Play, Plus, Search, Square, Tag as TagIcon, Trash2 } from "lucide-react";
|
import { CalendarDays, Check, ChevronDown, Clock3, DollarSign, MoreVertical, Pencil, Play, Plus, Search, Square, Tag as TagIcon, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -25,6 +26,11 @@ import { Input } from "../components/ui/input";
|
|||||||
import { SearchableSelect } from "../components/ui/SearchableSelect";
|
import { SearchableSelect } from "../components/ui/SearchableSelect";
|
||||||
import { useWorkspace } from "../context/WorkspaceContext";
|
import { useWorkspace } from "../context/WorkspaceContext";
|
||||||
import { useTranslation } from "../hooks/useTranslation";
|
import { useTranslation } from "../hooks/useTranslation";
|
||||||
|
import {
|
||||||
|
readArrayParam,
|
||||||
|
readStringParam,
|
||||||
|
updateQueryParams,
|
||||||
|
} from "../lib/queryParams";
|
||||||
|
|
||||||
type EntryModalMode = "manual" | "edit" | null;
|
type EntryModalMode = "manual" | "edit" | null;
|
||||||
|
|
||||||
@@ -2009,9 +2015,10 @@ function TimesheetSkeleton({ loadingLabel }: { loadingLabel: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Timesheet() {
|
export default function Timesheet() {
|
||||||
const { t, lang } = useTranslation();
|
const { t, lang } = useTranslation();
|
||||||
const { activeWorkspace } = useWorkspace();
|
const { activeWorkspace } = useWorkspace();
|
||||||
const isRtl = lang === "fa";
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const isRtl = lang === "fa";
|
||||||
const extendedTimesheet = (t.timesheet as {
|
const extendedTimesheet = (t.timesheet as {
|
||||||
deleteTitle?: string;
|
deleteTitle?: string;
|
||||||
deleteConfirmMessage?: string;
|
deleteConfirmMessage?: string;
|
||||||
@@ -2047,10 +2054,19 @@ export default function Timesheet() {
|
|||||||
const [activeRunningEntry, setActiveRunningEntry] = useState<TimeEntry | null>(null);
|
const [activeRunningEntry, setActiveRunningEntry] = useState<TimeEntry | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
|
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
|
||||||
const [filters, setFilters] = useState<TimeEntryFilters>(DEFAULT_ENTRY_FILTERS);
|
const searchQuery = readStringParam(searchParams, "search", "");
|
||||||
const [hasMoreHistory, setHasMoreHistory] = useState(false);
|
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 [nextOffset, setNextOffset] = useState<number | null>(0);
|
||||||
const [limit] = useState(20);
|
const [limit] = useState(20);
|
||||||
const [ticker, setTicker] = useState(Date.now());
|
const [ticker, setTicker] = useState(Date.now());
|
||||||
@@ -2134,9 +2150,6 @@ export default function Timesheet() {
|
|||||||
}, [activeWorkspace?.id, t.timesheet?.optionsError]);
|
}, [activeWorkspace?.id, t.timesheet?.optionsError]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSearchQuery("");
|
|
||||||
setDebouncedSearchQuery("");
|
|
||||||
setFilters(DEFAULT_ENTRY_FILTERS);
|
|
||||||
setGroupedHistory([]);
|
setGroupedHistory([]);
|
||||||
setNextOffset(0);
|
setNextOffset(0);
|
||||||
setHasMoreHistory(false);
|
setHasMoreHistory(false);
|
||||||
@@ -2163,10 +2176,14 @@ export default function Timesheet() {
|
|||||||
(project) => project.id === filters.projectId && project.client?.id === filters.clientId,
|
(project) => project.id === filters.projectId && project.client?.id === filters.clientId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!projectStillMatchesClient) {
|
if (!projectStillMatchesClient) {
|
||||||
setFilters((current) => ({ ...current, projectId: "" }));
|
setSearchParams(
|
||||||
}
|
(current) =>
|
||||||
}, [filters.clientId, filters.projectId, projects]);
|
updateQueryParams(current, { project: "" }, { project: "" }),
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [filters.clientId, filters.projectId, projects, setSearchParams]);
|
||||||
|
|
||||||
const loadHistory = useCallback(async ({ offset = 0, append = false }: { offset?: number; append?: boolean } = {}) => {
|
const loadHistory = useCallback(async ({ offset = 0, append = false }: { offset?: number; append?: boolean } = {}) => {
|
||||||
if (!activeWorkspace?.id) return;
|
if (!activeWorkspace?.id) return;
|
||||||
@@ -2523,14 +2540,53 @@ export default function Timesheet() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleApplyFilters = useCallback((nextFilters: TimeEntryFilters) => {
|
const handleApplyFilters = useCallback((nextFilters: TimeEntryFilters) => {
|
||||||
setFilters(nextFilters);
|
setSearchParams(
|
||||||
}, []);
|
(current) =>
|
||||||
|
updateQueryParams(
|
||||||
|
current,
|
||||||
|
{
|
||||||
|
project: nextFilters.projectId,
|
||||||
|
client: nextFilters.clientId,
|
||||||
|
tags: nextFilters.tagIds,
|
||||||
|
from: nextFilters.startedAfter,
|
||||||
|
to: nextFilters.startedBefore,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
project: "",
|
||||||
|
client: "",
|
||||||
|
from: "",
|
||||||
|
to: "",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
}, [setSearchParams]);
|
||||||
|
|
||||||
const handleClearFilters = useCallback(() => {
|
const handleClearFilters = useCallback(() => {
|
||||||
setSearchQuery("");
|
setSearchParams(
|
||||||
|
(current) =>
|
||||||
|
updateQueryParams(
|
||||||
|
current,
|
||||||
|
{
|
||||||
|
search: "",
|
||||||
|
project: "",
|
||||||
|
client: "",
|
||||||
|
tags: [],
|
||||||
|
from: "",
|
||||||
|
to: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
search: "",
|
||||||
|
project: "",
|
||||||
|
client: "",
|
||||||
|
from: "",
|
||||||
|
to: "",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
setDebouncedSearchQuery("");
|
setDebouncedSearchQuery("");
|
||||||
setFilters(DEFAULT_ENTRY_FILTERS);
|
}, [setSearchParams]);
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLoadMore = useCallback(() => {
|
const handleLoadMore = useCallback(() => {
|
||||||
if (!hasMoreHistory || nextOffset === null || isLoadingMore) return;
|
if (!hasMoreHistory || nextOffset === null || isLoadingMore) return;
|
||||||
@@ -2789,7 +2845,13 @@ export default function Timesheet() {
|
|||||||
<TimesheetFilterBar
|
<TimesheetFilterBar
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onSearchChange={setSearchQuery}
|
onSearchChange={(value) =>
|
||||||
|
setSearchParams(
|
||||||
|
(current) =>
|
||||||
|
updateQueryParams(current, { search: value }, { search: "" }),
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
onApply={handleApplyFilters}
|
onApply={handleApplyFilters}
|
||||||
onClearFilters={handleClearFilters}
|
onClearFilters={handleClearFilters}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { Plus, Trash2, Pencil, Eye } from 'lucide-react';
|
import { Plus, Trash2, Pencil, Eye, LayoutDashboard } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { fetchWorkspaces, deleteWorkspace, type Workspace } from '../api/workspaces';
|
import { fetchWorkspaces, deleteWorkspace, type Workspace } from '../api/workspaces';
|
||||||
import { useTranslation } from '../hooks/useTranslation';
|
import { useTranslation } from '../hooks/useTranslation';
|
||||||
@@ -17,6 +17,7 @@ import { Input } from '../components/ui/input';
|
|||||||
import { Card, CardContent, CardTitle } from '../components/ui/card';
|
import { Card, CardContent, CardTitle } from '../components/ui/card';
|
||||||
import { Pagination } from '../components/Pagination';
|
import { Pagination } from '../components/Pagination';
|
||||||
import { Modal } from '../components/Modal';
|
import { Modal } from '../components/Modal';
|
||||||
|
import { readNumberParam, readStringParam, updateQueryParams } from '../lib/queryParams';
|
||||||
|
|
||||||
const RoleBadge = ({ role }: { role?: WorkspaceRole }) => {
|
const RoleBadge = ({ role }: { role?: WorkspaceRole }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -39,18 +40,18 @@ const RoleBadge = ({ role }: { role?: WorkspaceRole }) => {
|
|||||||
export default function Workspaces() {
|
export default function Workspaces() {
|
||||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [ordering, setOrdering] = useState('-created_at');
|
|
||||||
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [totalItems, setTotalItems] = useState(0);
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
const [limit, setLimit] = useState(10);
|
|
||||||
|
|
||||||
const [deleteModal, setDeleteModal] = useState<{isOpen: boolean; workspace: Workspace | null}>({isOpen: false, workspace: null});
|
const [deleteModal, setDeleteModal] = useState<{isOpen: boolean; workspace: Workspace | null}>({isOpen: false, workspace: null});
|
||||||
const [deleteInput, setDeleteInput] = useState('');
|
const [deleteInput, setDeleteInput] = useState('');
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const searchQuery = readStringParam(searchParams, 'search', '');
|
||||||
|
const ordering = readStringParam(searchParams, 'ordering', '-created_at');
|
||||||
|
const currentPage = Math.max(1, readNumberParam(searchParams, 'page', 1));
|
||||||
|
const limit = Math.max(1, readNumberParam(searchParams, 'limit', 10));
|
||||||
|
|
||||||
const orderingOptions = [
|
const orderingOptions = [
|
||||||
{ value: '-created_at', label: t.ordering?.createdAtDesc || 'Newest First' },
|
{ value: '-created_at', label: t.ordering?.createdAtDesc || 'Newest First' },
|
||||||
@@ -60,10 +61,6 @@ export default function Workspaces() {
|
|||||||
{ value: '-updated_at', label: t.ordering?.updatedAtDesc || 'Recently Updated' },
|
{ value: '-updated_at', label: t.ordering?.updatedAtDesc || 'Recently Updated' },
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentPage(1);
|
|
||||||
}, [searchQuery, ordering]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
loadWorkspaces();
|
loadWorkspaces();
|
||||||
@@ -116,6 +113,19 @@ export default function Workspaces() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateListParams = (updates: Record<string, string | number | null | undefined>) => {
|
||||||
|
setSearchParams(
|
||||||
|
(current) =>
|
||||||
|
updateQueryParams(current, updates, {
|
||||||
|
search: '',
|
||||||
|
ordering: '-created_at',
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
}),
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex min-h-full max-w-7xl flex-col p-4 md:p-6">
|
<div className="mx-auto flex min-h-full max-w-7xl flex-col p-4 md:p-6">
|
||||||
<div className="flex flex-1 flex-col gap-5">
|
<div className="flex flex-1 flex-col gap-5">
|
||||||
@@ -139,9 +149,9 @@ export default function Workspaces() {
|
|||||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
|
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
|
||||||
<FilterBar
|
<FilterBar
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
setSearchQuery={setSearchQuery}
|
setSearchQuery={(value) => updateListParams({ search: value, page: 1 })}
|
||||||
ordering={ordering}
|
ordering={ordering}
|
||||||
setOrdering={setOrdering}
|
setOrdering={(value) => updateListParams({ ordering: value, page: 1 })}
|
||||||
orderingOptions={orderingOptions}
|
orderingOptions={orderingOptions}
|
||||||
searchPlaceholder={t.workspace?.searchPlaceholder || 'Search...'}
|
searchPlaceholder={t.workspace?.searchPlaceholder || 'Search...'}
|
||||||
/>
|
/>
|
||||||
@@ -220,10 +230,12 @@ export default function Workspaces() {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{workspaces.length === 0 && (
|
{workspaces.length === 0 && (
|
||||||
<div className="flex flex-1 rounded-3xl border-2 border-dashed border-slate-200 bg-white py-16 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
<div className="flex flex-col flex-1 rounded-3xl border-2 border-dashed border-slate-200 bg-white p-12 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
<div className="flex flex-col items-center justify-center">
|
<LayoutDashboard className="mx-auto mb-3 h-12 w-12 text-slate-300 dark:text-slate-700" />
|
||||||
<p className="text-slate-500 dark:text-slate-400 font-medium">{t.workspace?.emptyState || 'No workspaces found'}</p>
|
<h3 className="text-lg font-medium text-slate-900 dark:text-white">{t.workspace.noWorkspace}</h3>
|
||||||
</div>
|
<p className="mt-1 text-slate-500 dark:text-slate-400">
|
||||||
|
{searchQuery ? t.workspace.noWorkspaceSearch : t.workspace?.emptyState}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -232,8 +244,8 @@ export default function Workspaces() {
|
|||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalCount={totalItems}
|
totalCount={totalItems}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
onPageChange={setCurrentPage}
|
onPageChange={(page) => updateListParams({ page })}
|
||||||
onLimitChange={setLimit}
|
onLimitChange={(pageLimit) => updateListParams({ limit: pageLimit, page: 1 })}
|
||||||
pageSizeOptions={[10, 20, 50]}
|
pageSizeOptions={[10, 20, 50]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user