diff --git a/src/api/projects.ts b/src/api/projects.ts index 24de159..a9dc218 100644 --- a/src/api/projects.ts +++ b/src/api/projects.ts @@ -17,6 +17,7 @@ export interface Project { name: string; description: string; color: string; + created_at?: string; is_archived: boolean; is_deleted?: boolean; workspace: string; @@ -33,24 +34,42 @@ export interface ProjectPayload { client: string | null; } -export const getProjects = async ( - workspaceId: string, - params: { limit?: number; offset?: number; search?: string; client?: string; is_archived?: boolean, ordering?: string } = {} -) => { - const queryParams = new URLSearchParams({ workspace: workspaceId }); +export const getProjects = async ( + workspaceId: string, + params: { + limit?: number; + 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.offset !== undefined) queryParams.append("offset", params.offset.toString()); - if (params.search) queryParams.append("search", params.search); - if (params.client) queryParams.append("client", params.client); - if (params.is_archived !== undefined) queryParams.append("is_archived", params.is_archived.toString()); + if (params.search) queryParams.append("search", params.search); + if (params.client) queryParams.append("client", params.client); + 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()); - const response = await authFetch(`/api/projects/?${queryParams.toString()}`); - - if (!response.ok) throw new Error("Failed to fetch projects"); - return response.json(); -}; + const response = await authFetch(`/api/projects/?${queryParams.toString()}`); + + if (!response.ok) throw new Error("Failed to fetch projects"); + 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) => { const response = await authFetch(`/api/projects/${id}/`); diff --git a/src/components/projects/ProjectCreateModal.tsx b/src/components/projects/ProjectCreateModal.tsx index fc7382b..b83a59d 100644 --- a/src/components/projects/ProjectCreateModal.tsx +++ b/src/components/projects/ProjectCreateModal.tsx @@ -44,24 +44,26 @@ export const ProjectCreateModal: React.FC = ({ isOpen, if (!activeWorkspace || !formData.name) return; setLoading(true); - try { - const newProject = await createProject({ - workspace: activeWorkspace.id, - name: formData.name, - description: formData.description, + try { + const newProject = await createProject({ + workspace: activeWorkspace.id, + name: formData.name, + description: formData.description, color: formData.color, client: formData.client || null, - }); - - window.dispatchEvent(new CustomEvent("project_created", { detail: newProject })); - onClose(); - setFormData({ name: "", description: "", color: "#3B82F6", client: "" }); - } catch (error) { - console.error(error); - } finally { - setLoading(false); - } - }; + }); + + toast.success(t.projects?.createSuccess || "Project created successfully."); + window.dispatchEvent(new CustomEvent("project_created", { detail: newProject })); + onClose(); + setFormData({ name: "", description: "", color: "#3B82F6", client: "" }); + } catch (error) { + console.error(error); + toast.error(t.projects?.createError || "Failed to create project."); + } finally { + setLoading(false); + } + }; const footer = ( <> diff --git a/src/components/projects/ProjectEditModal.tsx b/src/components/projects/ProjectEditModal.tsx index 2bf8ee0..09331a7 100644 --- a/src/components/projects/ProjectEditModal.tsx +++ b/src/components/projects/ProjectEditModal.tsx @@ -58,36 +58,44 @@ export const ProjectEditModal: React.FC = ({ isOpen, onCl if (!project || !formData.name) return; setLoading(true); - try { - const updated = await updateProject(project.id, { - name: formData.name, - description: formData.description, - color: formData.color, - client: formData.client || null, - }); - - window.dispatchEvent(new CustomEvent("project_updated", { detail: updated })); - onClose(); - } catch (error) { - console.error(error); - } finally { - setLoading(false); - } - }; + try { + const updated = await updateProject(project.id, { + name: formData.name, + description: formData.description, + color: formData.color, + client: formData.client || null, + }); + + toast.success(t.projects?.updateSuccess || "Project updated successfully."); + 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 handleArchiveToggle = async () => { if (!project) return; setLoading(true); - try { - const updated = await toggleArchiveProject(project.id); - window.dispatchEvent(new CustomEvent("project_updated", { detail: updated })); - onClose(); - } catch (error) { - console.error(error); - } finally { - setLoading(false); - } - }; + try { + const updated = await toggleArchiveProject(project.id); + toast.success( + project?.is_archived + ? t.projects?.restoreSuccess || t.projects?.updateSuccess || "Project updated successfully." + : t.projects?.archiveSuccess || t.projects?.updateSuccess || "Project updated successfully.", + ); + 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 = (
diff --git a/src/locales/en.ts b/src/locales/en.ts index 985abbf..ec39d76 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -292,10 +292,11 @@ export const en = { description: (workspaceName: string) => `Manage projects for ${workspaceName}`, active: "Active Projects", archived: "Archived Projects", - createNew: "Create New", - searchPlaceholder: "Search projects...", - titlePlaceholder: "Enter title", - descriptionPlaceholder: "Enter desription", + createNew: "Create New", + searchPlaceholder: "Search projects...", + selectWorkspace: "Please select a workspace first.", + titlePlaceholder: "Enter title", + descriptionPlaceholder: "Enter desription", titleLabel: "Title", clientLabel: "Client", colorLabel: "Color", @@ -313,8 +314,13 @@ export const en = { createProject: "Create New Project", editProject: "Edit Project", restore: "Restore", - archive: "Archive", - clientFetchError: "Failed to load clients.", + archive: "Archive", + 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...", teamMembers: "Team Members", creator: "Creator", diff --git a/src/locales/fa.ts b/src/locales/fa.ts index d982c8b..fcf162b 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -288,10 +288,11 @@ export const fa = { title: "پروژه‌ها", description: (workspaceName: string) => `مدیریت پروژه‌ها برای ${workspaceName}`, active: "پروژه‌های فعال", - archived: "پروژه‌های بایگانی شده", - createNew: "ایجاد پروژه جدید", - searchPlaceholder: "جستجوی پروژه‌ها...", - titlePlaceholder: "عنوان پروژه", + archived: "پروژه‌های بایگانی شده", + createNew: "ایجاد پروژه جدید", + searchPlaceholder: "جستجوی پروژه‌ها...", + selectWorkspace: "لطفاً ابتدا یک ورک‌اسپیس انتخاب کنید.", + titlePlaceholder: "عنوان پروژه", descriptionPlaceholder: "توضیحات پروژه", titleLabel: "عنوان", descriptionLabel: "توضیحات", @@ -308,10 +309,15 @@ export const fa = { create: "ایجاد", cancel: "انصراف", createProject: "ایجاد پروژه", - editProject: "ویرایش پروژه", - restore: "بازیابی", - archive: "بایگانی", - clientFetchError: "خطا در دریافت لیست مشتری‌ها.", + editProject: "ویرایش پروژه", + restore: "بازیابی", + archive: "بایگانی", + archiveSuccess: "پروژه با موفقیت بایگانی شد.", + restoreSuccess: "پروژه با موفقیت بازیابی شد.", + fetchError: "خطا در دریافت پروژه‌ها.", + clientFetchError: "خطا در دریافت لیست مشتری‌ها.", + filterClients: "فیلتر بر اساس مشتری", + clearClientFilters: "پاک کردن فیلترها", memberAlreadyAdded: "این کاربر قبلا اضافه شده است", creator: "سازنده", addUser: "افزودن کاربر", diff --git a/src/pages/Projects.tsx b/src/pages/Projects.tsx index e2311c0..abba343 100644 --- a/src/pages/Projects.tsx +++ b/src/pages/Projects.tsx @@ -1,19 +1,20 @@ -import React, { useState, useEffect } from "react"; -import { useTranslation } from "../hooks/useTranslation"; +import React, { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "../hooks/useTranslation"; import { getProjects, deleteProject, type Project } from "../api/projects"; +import { getClients } from "../api/clients"; import { useAppContext } from "../context/AppContext"; import { useWorkspace } from "../context/WorkspaceContext"; -import { ProjectCreateModal } from "../components/projects/ProjectCreateModal"; -import { ProjectEditModal } from "../components/projects/ProjectEditModal"; -import { Pagination } from "../components/Pagination"; -import { Plus, Archive, Trash2, Pencil } from "lucide-react"; - -import FilterBar from "../components/FilterBar"; -import { Button } from "../components/ui/button"; -import { Card } from "../components/ui/card"; -import { Modal } from "../components/Modal"; -import { toast } from "sonner"; -import { Input } from "../components/ui/input"; +import { ProjectCreateModal } from "../components/projects/ProjectCreateModal"; +import { ProjectEditModal } from "../components/projects/ProjectEditModal"; +import { Pagination } from "../components/Pagination"; +import { Plus, Archive, Building2, Pencil, Trash2, X } from "lucide-react"; + +import FilterBar from "../components/FilterBar"; +import { Button } from "../components/ui/button"; +import { Card, CardContent, CardTitle } from "../components/ui/card"; +import { Modal } from "../components/Modal"; +import { toast } from "sonner"; +import { Input } from "../components/ui/input"; import { PROJECTS_ARCHIVE, PROJECTS_CREATE, @@ -22,7 +23,7 @@ import { canWorkspace, } from "../lib/permissions"; -export const Projects: React.FC = () => { +export const Projects: React.FC = () => { const { t, lang } = useTranslation(); const { user } = useAppContext(); const { activeWorkspace } = useWorkspace(); @@ -31,62 +32,84 @@ export const Projects: React.FC = () => { const canEditProject = canWorkspace(workspaceRole, PROJECTS_EDIT); const canArchiveProject = canWorkspace(workspaceRole, PROJECTS_ARCHIVE); - const [projects, setProjects] = useState([]); - const [loading, setLoading] = useState(false); - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); - const [editingProject, setEditingProject] = useState(null); + const [projects, setProjects] = useState([]); + const [clients, setClients] = useState<{ id: string; name: string }[]>([]); + const [loading, setLoading] = useState(false); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [editingProject, setEditingProject] = useState(null); + + const [search, setSearch] = useState(""); + const [ordering, setOrdering] = useState("-created_at"); + const [isArchived, setIsArchived] = useState(false); + const [selectedClientIds, setSelectedClientIds] = useState([]); + 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 [ordering, setOrdering] = useState("-created_at"); - const [isArchived, setIsArchived] = useState(false); - const [currentPage, setCurrentPage] = useState(1); - const [limit, setLimit] = useState(10); - const [totalItems, setTotalItems] = useState(0); - - const [projectToDelete, setProjectToDelete] = useState(null); - const [deleteModal, setDeleteModal] = useState<{isOpen: boolean; project: Project | null}>({isOpen: false, project: null}); - const [deleteInput, setDeleteInput] = useState(''); - - const orderingOptions = [ - { value: '-created_at', label: t.ordering?.createdAtDesc || 'Newest First' }, - { value: 'created_at', label: t.ordering?.createdAt || 'Oldest First' }, - { value: 'name', label: t.ordering?.name || 'Name (A-Z)' }, - { value: '-name', label: t.ordering?.nameDesc || 'Name (Z-A)' }, - ]; - - const fetchProjectList = async () => { - if (!activeWorkspace) return; - setLoading(true); - try { - const offset = (currentPage - 1) * limit; - const data = await getProjects(activeWorkspace.id, { - limit, - offset, - search, - is_archived: isArchived, - ordering - }); + const orderingOptions = [ + { value: '-created_at', label: t.ordering?.createdAtDesc || 'Newest First' }, + { value: 'created_at', label: t.ordering?.createdAt || 'Oldest First' }, + { value: 'name', label: t.ordering?.name || 'Name (A-Z)' }, + { value: '-name', label: t.ordering?.nameDesc || 'Name (Z-A)' }, + ]; + + useEffect(() => { + setCurrentPage(1); + }, [search, ordering, isArchived, selectedClientIds]); + + const fetchProjectList = async () => { + if (!activeWorkspace) return; + setLoading(true); + try { + const offset = (currentPage - 1) * limit; + const data = await getProjects(activeWorkspace.id, { + limit, + offset, + search, + clients: selectedClientIds, + is_archived: isArchived, + ordering + }); const items = data?.results || (Array.isArray(data) ? data : []) const count = data?.count !== undefined ? data.count : items.length - setProjects(items); - setTotalItems(count) - } catch (error) { - console.error("Failed to fetch projects", error); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - const delayDebounceFn = setTimeout(() => { - fetchProjectList(); - }, 300); - return () => clearTimeout(delayDebounceFn); - }, [activeWorkspace, currentPage, limit, search, isArchived, ordering]); - - useEffect(() => { - const handleCreated = () => fetchProjectList(); - const handleUpdated = () => fetchProjectList(); + setProjects(items); + setTotalItems(count) + } catch (error) { + console.error("Failed to fetch projects", error); + toast.error(t.projects?.fetchError || "Failed to fetch projects."); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (!activeWorkspace?.id) return; + + getClients(activeWorkspace.id, "", "name", 300, 0) + .then((data: any) => { + const items = data?.results || (Array.isArray(data) ? data : []); + setClients(items.map((client: { id: string; name: string }) => ({ id: client.id, name: client.name }))); + }) + .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_updated", handleUpdated); @@ -97,13 +120,9 @@ export const Projects: React.FC = () => { }; }, [activeWorkspace, currentPage, limit, search, isArchived, ordering]); - const handleDeleteClick = (project: Project) => { - setProjectToDelete(project); - }; - - const confirmDelete = async () => { - if (!deleteModal.project) return; - try { + const confirmDelete = async () => { + if (!deleteModal.project) return; + try { const deletedId = deleteModal.project.id; 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 "-" try { const date = new Date(dateStr) @@ -132,133 +151,237 @@ export const Projects: React.FC = () => { } catch { return dateStr } - } - - - return ( -
-
-
-

{t.projects?.title || 'Projects'}

-

{t.projects?.description(activeWorkspace?.name || "-") || 'Manage your projects'}

-
-
- {canArchiveProject && ( - - )} - {canCreateProject && ( - - )} -
-
- - - - {loading ? ( -
-
{t.projects?.loading || 'Loading...'}
-
- ) : ( -
- -
- {projects.length === 0 ? ( -
-

{t.projects?.emptyState || 'No projects found'}

-
- ) : ( -
    - {projects.map((project) => { - const canDeleteProject = canDeleteWorkspaceResource({ - workspaceRole, - currentUserId: user?.id, - createdById: project.created_by?.id, - }); - return ( -
  • -
    -

    {project.name}

    -

    - {project.client ? `${t.projects?.client || "Client"}: ${project.client.name}` : t.projects?.noClient || "No client"} -

    - {project.description && ( -

    - {project.description} -

    - )} -
    - - {(canEditProject || canDeleteProject) && ( -
    - {canEditProject && ( - - )} - - {canDeleteProject && ( - - )} -
    - )} -
  • - ); - })} -
- )} -
-
- - -
- )} - - {/* Modals */} + } + + const sortedClients = useMemo(() => { + if (!selectedClientIds.length) return clients; + const selected = clients.filter((client) => selectedClientIds.includes(client.id)); + const unselected = clients.filter((client) => !selectedClientIds.includes(client.id)); + return [...selected, ...unselected]; + }, [clients, selectedClientIds]); + + const toggleClientFilter = (clientId: string) => { + setCurrentPage(1); + setSelectedClientIds((current) => + current.includes(clientId) + ? current.filter((id) => id !== clientId) + : [...current, clientId], + ); + }; + + if (!activeWorkspace) { + return ( +
+
+ {t.projects?.selectWorkspace || t.clients.selectWorkspace} +
+
+ ); + } + + + return ( +
+
+
+
+

{t.projects?.title || 'Projects'}

+

{t.projects?.description(activeWorkspace.name) || 'Manage your projects'}

+
+
+ {canArchiveProject && ( + + )} + {canCreateProject && ( + + )} +
+
+
+ +
+ + +
+
+ {t.projects?.filterClients || "Filter by client"} +
+ {selectedClientIds.length > 0 ? ( + + ) : null} +
+ +
+
+ {sortedClients.map((client) => { + const isSelected = selectedClientIds.includes(client.id); + return ( + + ); + })} +
+
+
+ + {loading ? ( +
+
{t.projects?.loading || 'Loading...'}
+
+ ) : ( +
+ {projects.length === 0 ? ( +
+ +

{t.projects?.emptyState || 'No projects found'}

+
+ ) : ( +
+ {projects.map((project) => { + const canDeleteProject = canDeleteWorkspaceResource({ + workspaceRole, + currentUserId: user?.id, + createdById: project.created_by?.id, + }); + + return ( + + +
+
+
+
+ {project.name} +
+ {project.client ? `${t.projects?.client || "Client"}: ${project.client.name}` : t.projects?.noClient || "No client"} +
+
+
+ + {(canEditProject || canDeleteProject) && ( +
+ {canEditProject && ( + + )} + + {canDeleteProject && ( + + )} +
+ )} +
+ +
+

+ {project.description || t.workspace?.noDescription || "No description"} +

+
+ {formatDate(project.created_at)} + {project.is_archived ? ( + + {t.projects?.archived || "Archived Projects"} + + ) : null} +
+
+ + + ); + })} +
+ )} + + +
+ )} + + {/* Modals */} {canCreateProject && isCreateModalOpen && (