feat(projects): improve list filters
This commit is contained in:
@@ -9,7 +9,7 @@ import { ProjectCreateModal } from "../components/projects/ProjectCreateModal";
|
||||
import { ProjectEditModal } from "../components/projects/ProjectEditModal";
|
||||
import { ProjectAccessModal } from "../components/projects/ProjectAccessModal";
|
||||
import { Pagination } from "../components/Pagination";
|
||||
import { Plus, Archive, Building2, Pencil, ShieldCheck, Trash2, X } from "lucide-react";
|
||||
import { Plus, Building2, Pencil, ShieldCheck, Trash2, X } from "lucide-react";
|
||||
|
||||
import EmptyStateCard from "../components/EmptyStateCard";
|
||||
import FilterBar from "../components/FilterBar";
|
||||
@@ -19,6 +19,7 @@ import { Card, CardContent, CardTitle } from "../components/ui/card";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { toast } from "sonner";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { MultiSearchableSelect } from "../components/ui/MultiSearchableSelect";
|
||||
import {
|
||||
PROJECTS_ARCHIVE,
|
||||
PROJECTS_CREATE,
|
||||
@@ -188,13 +189,16 @@ export const Projects: React.FC = () => {
|
||||
return [...selected, ...unselected];
|
||||
}, [clients, selectedClientIdsKey]);
|
||||
|
||||
const toggleClientFilter = (clientId: string) => {
|
||||
const nextClientIds = selectedClientIds.includes(clientId)
|
||||
? selectedClientIds.filter((id) => id !== clientId)
|
||||
: [...selectedClientIds, clientId];
|
||||
const clientOptions = useMemo(
|
||||
() =>
|
||||
sortedClients.map((client) => ({
|
||||
value: client.id,
|
||||
label: client.name,
|
||||
})),
|
||||
[sortedClients],
|
||||
);
|
||||
|
||||
updateListParams({ clients: nextClientIds, page: 1 });
|
||||
};
|
||||
const hasActiveProjectFilters = selectedClientIds.length > 0 || isArchived;
|
||||
|
||||
const updateListParams = (
|
||||
updates: Record<string, string | number | boolean | null | undefined | string[]>,
|
||||
@@ -243,16 +247,6 @@ export const Projects: React.FC = () => {
|
||||
{t.projects?.manageAccess || "Manage access"}
|
||||
</Button>
|
||||
)}
|
||||
{canArchiveProject && (
|
||||
<Button
|
||||
variant={isArchived ? "default" : "secondary"}
|
||||
onClick={() => updateListParams({ archived: !isArchived, page: 1 })}
|
||||
className="flex-1 gap-2 shadow-sm sm:flex-none"
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
{isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')}
|
||||
</Button>
|
||||
)}
|
||||
{canCreateProject && (
|
||||
<Button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
@@ -277,63 +271,81 @@ export const Projects: React.FC = () => {
|
||||
searchPlaceholder={t.projects?.searchPlaceholder || 'Search projects...'}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
|
||||
{t.projects?.filterClients || "Filter by client"}
|
||||
</div>
|
||||
{selectedClientIds.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
{t.projects?.clearClientFilters || "Clear filters"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] lg:flex-1">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
|
||||
{t.projects?.filterClients || "Filter by client"}
|
||||
</div>
|
||||
<MultiSearchableSelect
|
||||
values={selectedClientIds}
|
||||
onChange={(values) => updateListParams({ clients: values, page: 1 })}
|
||||
options={clientOptions}
|
||||
placeholder={t.reports?.allClients || "All clients"}
|
||||
searchPlaceholder={t.reports?.searchClients || "Search clients..."}
|
||||
emptyLabel={t.clients?.noClients || "No clients found"}
|
||||
renderValue={(selectedOptions) => {
|
||||
if (selectedOptions.length === 0) {
|
||||
return t.reports?.allClients || "All clients";
|
||||
}
|
||||
if (selectedOptions.length <= 2) {
|
||||
return selectedOptions.map((option) => option.label).join(", ");
|
||||
}
|
||||
return `${selectedOptions[0]?.label} +${selectedOptions.length - 1}`;
|
||||
}}
|
||||
buttonClassName="min-h-11 w-full rounded-xl border-slate-200 bg-slate-50/80 dark:border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 overflow-x-auto pb-2">
|
||||
<div className="flex min-w-max items-center gap-2">
|
||||
{sortedClients.map((client) => {
|
||||
const isSelected = selectedClientIds.includes(client.id);
|
||||
return (
|
||||
{canArchiveProject ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
|
||||
{t.projects?.archived || "Archived Projects"}
|
||||
</div>
|
||||
<button
|
||||
key={client.id}
|
||||
type="button"
|
||||
onClick={() => toggleClientFilter(client.id)}
|
||||
className={`inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm font-medium transition ${
|
||||
isSelected
|
||||
? "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-300"
|
||||
: "border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-950/60 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800"
|
||||
role="switch"
|
||||
aria-checked={isArchived}
|
||||
aria-label={t.projects?.archived || "Archived Projects"}
|
||||
onClick={() => updateListParams({ archived: !isArchived, page: 1 })}
|
||||
className={`inline-flex min-h-11 w-full items-center justify-between gap-3 rounded-xl border px-3 py-2 text-sm font-medium transition md:w-auto ${
|
||||
isArchived
|
||||
? "border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-300"
|
||||
: "border-slate-200 bg-slate-50/80 text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-950/60 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<span className="whitespace-nowrap">{client.name}</span>
|
||||
{isSelected ? (
|
||||
<span>{t.projects?.archived || "Archived Projects"}</span>
|
||||
<span
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition ${
|
||||
isArchived ? "bg-amber-500 dark:bg-amber-400" : "bg-slate-300 dark:bg-slate-700"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleClientFilter(client.id);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggleClientFilter(client.id);
|
||||
}
|
||||
}}
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-200"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</span>
|
||||
) : null}
|
||||
className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transition-transform ${
|
||||
isArchived ? "translate-x-5 rtl:-translate-x-5" : "translate-x-0.5 rtl:-translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
updateListParams({ clients: [], archived: false, page: 1 });
|
||||
}}
|
||||
disabled={!hasActiveProjectFilters}
|
||||
aria-label={t.projects?.clearClientFilters || "Clear filters"}
|
||||
className={`inline-flex h-10 w-10 items-center justify-center self-start rounded-xl border text-sm transition lg:self-end ${
|
||||
hasActiveProjectFilters
|
||||
? "border-red-200 bg-red-50 text-red-700 hover:border-red-300 hover:bg-red-100 hover:text-red-800 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-300 dark:hover:border-red-400 dark:hover:bg-red-500/20 dark:hover:text-red-200"
|
||||
: "border-slate-200 bg-white text-slate-400 opacity-60 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-500"
|
||||
} disabled:cursor-not-allowed`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user