feat(projects): add client strip filtering and page refresh
This commit is contained in:
@@ -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}/`);
|
||||||
|
|||||||
@@ -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 = (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: "افزودن کاربر",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user