feat(projects): add client strip filtering and page refresh

This commit is contained in:
2026-04-29 00:53:55 +03:30
parent 36a8c0e24c
commit d57f0b05e3
6 changed files with 435 additions and 271 deletions

View File

@@ -17,6 +17,7 @@ export interface Project {
name: string; name: string;
description: string; description: string;
color: string; color: string;
created_at?: string;
is_archived: boolean; is_archived: boolean;
is_deleted?: boolean; is_deleted?: boolean;
workspace: string; workspace: string;
@@ -33,24 +34,42 @@ export interface ProjectPayload {
client: string | null; client: string | null;
} }
export const getProjects = async ( export const getProjects = async (
workspaceId: string, workspaceId: string,
params: { limit?: number; offset?: number; search?: string; client?: string; is_archived?: boolean, ordering?: string } = {} params: {
) => { limit?: number;
const queryParams = new URLSearchParams({ workspace: workspaceId }); offset?: number;
search?: string;
client?: string;
clients?: string[];
is_archived?: boolean;
ordering?: string;
} = {}
) => {
const queryParams = new URLSearchParams({ workspace: workspaceId });
if (params.limit !== undefined) queryParams.append("limit", params.limit.toString()); if (params.limit !== undefined) queryParams.append("limit", params.limit.toString());
if (params.offset !== undefined) queryParams.append("offset", params.offset.toString()); if (params.offset !== undefined) queryParams.append("offset", params.offset.toString());
if (params.search) queryParams.append("search", params.search); if (params.search) queryParams.append("search", params.search);
if (params.client) queryParams.append("client", params.client); if (params.client) queryParams.append("client", params.client);
if (params.is_archived !== undefined) queryParams.append("is_archived", params.is_archived.toString()); params.clients?.forEach((clientId) => queryParams.append("clients", clientId));
if (params.is_archived !== undefined) queryParams.append("is_archived", params.is_archived.toString());
if (params.ordering !== undefined) queryParams.append("ordering", params.ordering.toString()); if (params.ordering !== undefined) queryParams.append("ordering", params.ordering.toString());
const response = await authFetch(`/api/projects/?${queryParams.toString()}`); const response = await authFetch(`/api/projects/?${queryParams.toString()}`);
if (!response.ok) throw new Error("Failed to fetch projects"); if (!response.ok) throw new Error("Failed to fetch projects");
return response.json(); const data = await response.json();
}; if (Array.isArray(data)) return data;
if (Array.isArray(data?.items)) {
return {
...data,
results: data.items,
count: data.total_items ?? data.items.length,
};
}
return data;
};
export const getProject = async (id: string) => { export const getProject = async (id: string) => {
const response = await authFetch(`/api/projects/${id}/`); const response = await authFetch(`/api/projects/${id}/`);

View File

@@ -44,24 +44,26 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({ isOpen,
if (!activeWorkspace || !formData.name) return; if (!activeWorkspace || !formData.name) return;
setLoading(true); setLoading(true);
try { try {
const newProject = await createProject({ const newProject = await createProject({
workspace: activeWorkspace.id, workspace: activeWorkspace.id,
name: formData.name, name: formData.name,
description: formData.description, description: formData.description,
color: formData.color, color: formData.color,
client: formData.client || null, client: formData.client || null,
}); });
window.dispatchEvent(new CustomEvent("project_created", { detail: newProject })); toast.success(t.projects?.createSuccess || "Project created successfully.");
onClose(); window.dispatchEvent(new CustomEvent("project_created", { detail: newProject }));
setFormData({ name: "", description: "", color: "#3B82F6", client: "" }); onClose();
} catch (error) { setFormData({ name: "", description: "", color: "#3B82F6", client: "" });
console.error(error); } catch (error) {
} finally { console.error(error);
setLoading(false); toast.error(t.projects?.createError || "Failed to create project.");
} } finally {
}; setLoading(false);
}
};
const footer = ( const footer = (
<> <>

View File

@@ -58,36 +58,44 @@ export const ProjectEditModal: React.FC<ProjectEditModalProps> = ({ isOpen, onCl
if (!project || !formData.name) return; if (!project || !formData.name) return;
setLoading(true); setLoading(true);
try { try {
const updated = await updateProject(project.id, { const updated = await updateProject(project.id, {
name: formData.name, name: formData.name,
description: formData.description, description: formData.description,
color: formData.color, color: formData.color,
client: formData.client || null, client: formData.client || null,
}); });
window.dispatchEvent(new CustomEvent("project_updated", { detail: updated })); toast.success(t.projects?.updateSuccess || "Project updated successfully.");
onClose(); window.dispatchEvent(new CustomEvent("project_updated", { detail: updated }));
} catch (error) { onClose();
console.error(error); } catch (error) {
} finally { console.error(error);
setLoading(false); toast.error(t.projects?.updateError || "Failed to update project.");
} } finally {
}; setLoading(false);
}
};
const handleArchiveToggle = async () => { const handleArchiveToggle = async () => {
if (!project) return; if (!project) return;
setLoading(true); setLoading(true);
try { try {
const updated = await toggleArchiveProject(project.id); const updated = await toggleArchiveProject(project.id);
window.dispatchEvent(new CustomEvent("project_updated", { detail: updated })); toast.success(
onClose(); project?.is_archived
} catch (error) { ? t.projects?.restoreSuccess || t.projects?.updateSuccess || "Project updated successfully."
console.error(error); : t.projects?.archiveSuccess || t.projects?.updateSuccess || "Project updated successfully.",
} finally { );
setLoading(false); window.dispatchEvent(new CustomEvent("project_updated", { detail: updated }));
} onClose();
}; } catch (error) {
console.error(error);
toast.error(t.projects?.updateError || "Failed to update project.");
} finally {
setLoading(false);
}
};
const footer = ( const footer = (
<div className="flex justify-between w-full"> <div className="flex justify-between w-full">

View File

@@ -292,10 +292,11 @@ export const en = {
description: (workspaceName: string) => `Manage projects for ${workspaceName}`, description: (workspaceName: string) => `Manage projects for ${workspaceName}`,
active: "Active Projects", active: "Active Projects",
archived: "Archived Projects", archived: "Archived Projects",
createNew: "Create New", createNew: "Create New",
searchPlaceholder: "Search projects...", searchPlaceholder: "Search projects...",
titlePlaceholder: "Enter title", selectWorkspace: "Please select a workspace first.",
descriptionPlaceholder: "Enter desription", titlePlaceholder: "Enter title",
descriptionPlaceholder: "Enter desription",
titleLabel: "Title", titleLabel: "Title",
clientLabel: "Client", clientLabel: "Client",
colorLabel: "Color", colorLabel: "Color",
@@ -313,8 +314,13 @@ export const en = {
createProject: "Create New Project", createProject: "Create New Project",
editProject: "Edit Project", editProject: "Edit Project",
restore: "Restore", restore: "Restore",
archive: "Archive", archive: "Archive",
clientFetchError: "Failed to load clients.", archiveSuccess: "Project archived successfully.",
restoreSuccess: "Project restored successfully.",
fetchError: "Failed to fetch projects.",
clientFetchError: "Failed to load clients.",
filterClients: "Filter by client",
clearClientFilters: "Clear filters",
namePlaceholder: "Project name...", namePlaceholder: "Project name...",
teamMembers: "Team Members", teamMembers: "Team Members",
creator: "Creator", creator: "Creator",

View File

@@ -288,10 +288,11 @@ export const fa = {
title: "پروژه‌ها", title: "پروژه‌ها",
description: (workspaceName: string) => `مدیریت پروژه‌ها برای ${workspaceName}`, description: (workspaceName: string) => `مدیریت پروژه‌ها برای ${workspaceName}`,
active: "پروژه‌های فعال", active: "پروژه‌های فعال",
archived: "پروژه‌های بایگانی شده", archived: "پروژه‌های بایگانی شده",
createNew: "ایجاد پروژه جدید", createNew: "ایجاد پروژه جدید",
searchPlaceholder: "جستجوی پروژه‌ها...", searchPlaceholder: "جستجوی پروژه‌ها...",
titlePlaceholder: "عنوان پروژه", selectWorkspace: "لطفاً ابتدا یک ورک‌اسپیس انتخاب کنید.",
titlePlaceholder: "عنوان پروژه",
descriptionPlaceholder: "توضیحات پروژه", descriptionPlaceholder: "توضیحات پروژه",
titleLabel: "عنوان", titleLabel: "عنوان",
descriptionLabel: "توضیحات", descriptionLabel: "توضیحات",
@@ -308,10 +309,15 @@ export const fa = {
create: "ایجاد", create: "ایجاد",
cancel: "انصراف", cancel: "انصراف",
createProject: "ایجاد پروژه", createProject: "ایجاد پروژه",
editProject: "ویرایش پروژه", editProject: "ویرایش پروژه",
restore: "بازیابی", restore: "بازیابی",
archive: "بایگانی", archive: "بایگانی",
clientFetchError: "خطا در دریافت لیست مشتری‌ها.", archiveSuccess: "پروژه با موفقیت بایگانی شد.",
restoreSuccess: "پروژه با موفقیت بازیابی شد.",
fetchError: "خطا در دریافت پروژه‌ها.",
clientFetchError: "خطا در دریافت لیست مشتری‌ها.",
filterClients: "فیلتر بر اساس مشتری",
clearClientFilters: "پاک کردن فیلترها",
memberAlreadyAdded: "این کاربر قبلا اضافه شده است", memberAlreadyAdded: "این کاربر قبلا اضافه شده است",
creator: "سازنده", creator: "سازنده",
addUser: "افزودن کاربر", addUser: "افزودن کاربر",

View File

@@ -1,19 +1,20 @@
import React, { useState, useEffect } from "react"; import React, { useEffect, useMemo, useState } from "react";
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 { useAppContext } from "../context/AppContext"; import { useAppContext } from "../context/AppContext";
import { useWorkspace } from "../context/WorkspaceContext"; import { useWorkspace } from "../context/WorkspaceContext";
import { ProjectCreateModal } from "../components/projects/ProjectCreateModal"; import { ProjectCreateModal } from "../components/projects/ProjectCreateModal";
import { ProjectEditModal } from "../components/projects/ProjectEditModal"; import { ProjectEditModal } from "../components/projects/ProjectEditModal";
import { Pagination } from "../components/Pagination"; import { Pagination } from "../components/Pagination";
import { Plus, Archive, Trash2, Pencil } from "lucide-react"; import { Plus, Archive, Building2, Pencil, Trash2, X } from "lucide-react";
import FilterBar from "../components/FilterBar"; import FilterBar from "../components/FilterBar";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card"; 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,
@@ -22,7 +23,7 @@ import {
canWorkspace, canWorkspace,
} from "../lib/permissions"; } from "../lib/permissions";
export const Projects: React.FC = () => { export const Projects: React.FC = () => {
const { t, lang } = useTranslation(); const { t, lang } = useTranslation();
const { user } = useAppContext(); const { user } = useAppContext();
const { activeWorkspace } = useWorkspace(); const { activeWorkspace } = useWorkspace();
@@ -31,62 +32,84 @@ export const Projects: React.FC = () => {
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<any[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(false); const [clients, setClients] = useState<{ id: string; name: string }[]>([]);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [loading, setLoading] = useState(false);
const [editingProject, setEditingProject] = useState<any | null>(null); 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 [totalItems, setTotalItems] = useState(0);
const [deleteModal, setDeleteModal] = useState<{isOpen: boolean; project: Project | null}>({isOpen: false, project: null});
const [deleteInput, setDeleteInput] = useState('');
const [search, setSearch] = useState(""); const orderingOptions = [
const [ordering, setOrdering] = useState("-created_at"); { value: '-created_at', label: t.ordering?.createdAtDesc || 'Newest First' },
const [isArchived, setIsArchived] = useState(false); { value: 'created_at', label: t.ordering?.createdAt || 'Oldest First' },
const [currentPage, setCurrentPage] = useState(1); { value: 'name', label: t.ordering?.name || 'Name (A-Z)' },
const [limit, setLimit] = useState(10); { value: '-name', label: t.ordering?.nameDesc || 'Name (Z-A)' },
const [totalItems, setTotalItems] = useState(0); ];
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null); useEffect(() => {
const [deleteModal, setDeleteModal] = useState<{isOpen: boolean; project: Project | null}>({isOpen: false, project: null}); setCurrentPage(1);
const [deleteInput, setDeleteInput] = useState(''); }, [search, ordering, isArchived, selectedClientIds]);
const orderingOptions = [ const fetchProjectList = async () => {
{ value: '-created_at', label: t.ordering?.createdAtDesc || 'Newest First' }, if (!activeWorkspace) return;
{ value: 'created_at', label: t.ordering?.createdAt || 'Oldest First' }, setLoading(true);
{ value: 'name', label: t.ordering?.name || 'Name (A-Z)' }, try {
{ value: '-name', label: t.ordering?.nameDesc || 'Name (Z-A)' }, const offset = (currentPage - 1) * limit;
]; const data = await getProjects(activeWorkspace.id, {
limit,
const fetchProjectList = async () => { offset,
if (!activeWorkspace) return; search,
setLoading(true); clients: selectedClientIds,
try { is_archived: isArchived,
const offset = (currentPage - 1) * limit; ordering
const data = await getProjects(activeWorkspace.id, { });
limit,
offset,
search,
is_archived: isArchived,
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) {
console.error("Failed to fetch projects", error); console.error("Failed to fetch projects", error);
} finally { toast.error(t.projects?.fetchError || "Failed to fetch projects.");
setLoading(false); } finally {
} setLoading(false);
}; }
};
useEffect(() => {
const delayDebounceFn = setTimeout(() => { useEffect(() => {
fetchProjectList(); if (!activeWorkspace?.id) return;
}, 300);
return () => clearTimeout(delayDebounceFn); getClients(activeWorkspace.id, "", "name", 300, 0)
}, [activeWorkspace, currentPage, limit, search, isArchived, ordering]); .then((data: any) => {
const items = data?.results || (Array.isArray(data) ? data : []);
useEffect(() => { setClients(items.map((client: { id: string; name: string }) => ({ id: client.id, name: client.name })));
const handleCreated = () => fetchProjectList(); })
const handleUpdated = () => fetchProjectList(); .catch((error) => {
console.error(error);
toast.error(t.projects?.clientFetchError || "Failed to load clients.");
setClients([]);
});
}, [activeWorkspace?.id, t.projects?.clientFetchError]);
useEffect(() => {
const delayDebounceFn = setTimeout(() => {
void fetchProjectList();
}, 300);
return () => clearTimeout(delayDebounceFn);
}, [activeWorkspace, currentPage, limit, search, isArchived, ordering, selectedClientIds]);
useEffect(() => {
const handleCreated = () => 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);
@@ -97,13 +120,9 @@ export const Projects: React.FC = () => {
}; };
}, [activeWorkspace, currentPage, limit, search, isArchived, ordering]); }, [activeWorkspace, currentPage, limit, search, isArchived, ordering]);
const handleDeleteClick = (project: Project) => { const confirmDelete = async () => {
setProjectToDelete(project); if (!deleteModal.project) return;
}; try {
const confirmDelete = async () => {
if (!deleteModal.project) return;
try {
const deletedId = deleteModal.project.id; const deletedId = deleteModal.project.id;
await deleteProject(deletedId); await deleteProject(deletedId);
@@ -121,7 +140,7 @@ export const Projects: React.FC = () => {
} }
}; };
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)
@@ -132,133 +151,237 @@ export const Projects: React.FC = () => {
} catch { } catch {
return dateStr return dateStr
} }
} }
const sortedClients = useMemo(() => {
return ( if (!selectedClientIds.length) return clients;
<div className="flex flex-col p-6 min-h-[calc(100vh-73px)] bg-slate-50 dark:bg-slate-900"> const selected = clients.filter((client) => selectedClientIds.includes(client.id));
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4"> const unselected = clients.filter((client) => !selectedClientIds.includes(client.id));
<div> return [...selected, ...unselected];
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.projects?.title || 'Projects'}</h1> }, [clients, selectedClientIds]);
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.projects?.description(activeWorkspace?.name || "-") || 'Manage your projects'}</p>
</div> const toggleClientFilter = (clientId: string) => {
<div className="flex items-center gap-3 w-full sm:w-auto"> setCurrentPage(1);
{canArchiveProject && ( setSelectedClientIds((current) =>
<Button current.includes(clientId)
variant={isArchived ? "default" : "secondary"} ? current.filter((id) => id !== clientId)
onClick={() => setIsArchived(!isArchived)} : [...current, clientId],
className="gap-2 shadow-sm flex-1 sm:flex-none" );
> };
<Archive className="h-4 w-4" />
{isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')} if (!activeWorkspace) {
</Button> return (
)} <div className="mx-auto max-w-7xl p-4 md:p-6">
{canCreateProject && ( <div className="rounded-3xl border border-slate-200 bg-white p-6 text-slate-600 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
<Button {t.projects?.selectWorkspace || t.clients.selectWorkspace}
onClick={() => setIsCreateModalOpen(true)} </div>
size="icon" </div>
className="shadow-sm" );
title={t.projects?.createNew || 'Create New'} }
>
<Plus className="h-5 w-5" />
</Button> return (
)} <div className="mx-auto max-w-7xl space-y-5 p-4 md:p-6">
</div> <div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
</div> <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<FilterBar <h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.projects?.title || 'Projects'}</h1>
searchQuery={search} <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{t.projects?.description(activeWorkspace.name) || 'Manage your projects'}</p>
setSearchQuery={setSearch} </div>
ordering={ordering} <div className="flex w-full items-center gap-3 sm:w-auto">
setOrdering={setOrdering} {canArchiveProject && (
orderingOptions={orderingOptions} <Button
searchPlaceholder={t.projects?.searchPlaceholder || 'Search projects...'} variant={isArchived ? "default" : "secondary"}
/> onClick={() => setIsArchived(!isArchived)}
className="flex-1 gap-2 shadow-sm sm:flex-none"
{loading ? ( >
<div className="p-12 flex justify-center text-slate-500"> <Archive className="h-4 w-4" />
<div className="animate-pulse">{t.projects?.loading || 'Loading...'}</div> {isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')}
</div> </Button>
) : ( )}
<div className="flex flex-col flex-1"> {canCreateProject && (
<Card className="overflow-hidden dark:bg-slate-800 dark:border-slate-700 mb-6"> <Button
<div className="p-0"> onClick={() => setIsCreateModalOpen(true)}
{projects.length === 0 ? ( size="icon"
<div className="py-16 flex flex-col items-center justify-center"> className="shrink-0 shadow-sm"
<p className="text-slate-500 dark:text-slate-400 font-medium">{t.projects?.emptyState || 'No projects found'}</p> title={t.projects?.createNew || 'Create New'}
</div> >
) : ( <Plus className="h-5 w-5" />
<ul className="divide-y divide-slate-200 dark:divide-slate-800"> </Button>
{projects.map((project) => { )}
const canDeleteProject = canDeleteWorkspaceResource({ </div>
workspaceRole, </div>
currentUserId: user?.id, </div>
createdById: project.created_by?.id,
}); <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">
return ( <FilterBar
<li searchQuery={search}
key={project.id} setSearchQuery={setSearch}
className="p-4 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors flex items-center justify-between gap-4" ordering={ordering}
> setOrdering={setOrdering}
<div className="flex-1 min-w-0"> orderingOptions={orderingOptions}
<h4 className="font-medium text-slate-900 dark:text-white truncate">{project.name}</h4> searchPlaceholder={t.projects?.searchPlaceholder || 'Search projects...'}
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1 truncate"> />
{project.client ? `${t.projects?.client || "Client"}: ${project.client.name}` : t.projects?.noClient || "No client"}
</p> <div className="mt-4 flex items-center justify-between gap-3">
{project.description && ( <div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1 truncate"> {t.projects?.filterClients || "Filter by client"}
{project.description} </div>
</p> {selectedClientIds.length > 0 ? (
)} <button
</div> type="button"
onClick={() => {
{(canEditProject || canDeleteProject) && ( setCurrentPage(1);
<div className="flex items-center gap-1 shrink-0"> setSelectedClientIds([]);
{canEditProject && ( }}
<Button className="text-xs font-medium text-slate-500 transition hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
variant="ghost" >
size="icon" {t.projects?.clearClientFilters || "Clear filters"}
onClick={() => setEditingProject(project)} </button>
className="h-8 w-8 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900/20" ) : null}
title={t.actions?.edit || "Edit"} </div>
>
<Pencil className="w-4 h-4" /> <div className="mt-3 overflow-x-auto pb-2">
</Button> <div className="flex min-w-max items-center gap-2">
)} {sortedClients.map((client) => {
const isSelected = selectedClientIds.includes(client.id);
{canDeleteProject && ( return (
<Button <button
variant="ghost" key={client.id}
size="icon" type="button"
onClick={() => setDeleteModal({ isOpen: true, project })} onClick={() => toggleClientFilter(client.id)}
className="h-8 w-8 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900/20" className={`inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm font-medium transition ${
title={t.actions?.delete || "Delete"} isSelected
> ? "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-300"
<Trash2 className="w-4 h-4" /> : "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"
</Button> }`}
)} >
</div> <span className="whitespace-nowrap">{client.name}</span>
)} {isSelected ? (
</li> <span
); role="button"
})} tabIndex={0}
</ul> onClick={(event) => {
)} event.stopPropagation();
</div> toggleClientFilter(client.id);
</Card> }}
onKeyDown={(event) => {
<Pagination if (event.key === "Enter" || event.key === " ") {
currentPage={currentPage} event.preventDefault();
totalCount={totalItems} event.stopPropagation();
limit={limit} toggleClientFilter(client.id);
onPageChange={setCurrentPage} }
onLimitChange={setLimit} }}
pageSizeOptions={[10, 20, 50]} 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"
/> >
</div> <X className="h-3 w-3" />
)} </span>
) : null}
{/* Modals */} </button>
);
})}
</div>
</div>
</div>
{loading ? (
<div className="rounded-3xl border border-slate-200 bg-white p-12 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="text-center text-slate-500 dark:text-slate-400">{t.projects?.loading || 'Loading...'}</div>
</div>
) : (
<div className="space-y-6">
{projects.length === 0 ? (
<div className="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>
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 2xl:grid-cols-3">
{projects.map((project) => {
const canDeleteProject = canDeleteWorkspaceResource({
workspaceRole,
currentUserId: user?.id,
createdById: project.created_by?.id,
});
return (
<Card key={project.id} className="shadow-sm dark:border-slate-700 dark:bg-slate-800">
<CardContent className="flex h-full flex-col gap-4 p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div
className="h-10 w-10 shrink-0 rounded-xl border border-slate-200 dark:border-slate-700"
style={{ backgroundColor: project.color || "#3B82F6" }}
/>
<div className="min-w-0">
<CardTitle className="truncate text-base text-slate-900 dark:text-white">{project.name}</CardTitle>
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{project.client ? `${t.projects?.client || "Client"}: ${project.client.name}` : t.projects?.noClient || "No client"}
</div>
</div>
</div>
{(canEditProject || canDeleteProject) && (
<div className="flex shrink-0 items-center gap-1">
{canEditProject && (
<Button
variant="ghost"
size="icon"
onClick={() => setEditingProject(project)}
className="h-8 w-8 text-slate-400 hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
title={t.actions?.edit || "Edit"}
>
<Pencil className="h-4 w-4" />
</Button>
)}
{canDeleteProject && (
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteModal({ isOpen: true, project })}
className="h-8 w-8 text-slate-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
title={t.actions?.delete || "Delete"}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
)}
</div>
<div className="space-y-3">
<p className="min-h-[3.75rem] text-sm leading-6 text-slate-600 line-clamp-3 dark:text-slate-300">
{project.description || t.workspace?.noDescription || "No description"}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
<span>{formatDate(project.created_at)}</span>
{project.is_archived ? (
<span className="rounded-full bg-amber-100 px-2 py-1 text-[11px] font-semibold tracking-[0.1em] text-amber-700 dark:bg-amber-500/15 dark:text-amber-300">
{t.projects?.archived || "Archived Projects"}
</span>
) : null}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
<Pagination
currentPage={currentPage}
totalCount={totalItems}
limit={limit}
onPageChange={setCurrentPage}
onLimitChange={setLimit}
pageSizeOptions={[10, 20, 50]}
/>
</div>
)}
{/* Modals */}
{canCreateProject && isCreateModalOpen && ( {canCreateProject && isCreateModalOpen && (
<ProjectCreateModal <ProjectCreateModal
isOpen={isCreateModalOpen} isOpen={isCreateModalOpen}