328 lines
13 KiB
TypeScript
328 lines
13 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import { useTranslation } from "../hooks/useTranslation";
|
|
import { getProjects, deleteProject, type Project } from "../api/projects";
|
|
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 {
|
|
PROJECTS_ARCHIVE,
|
|
PROJECTS_CREATE,
|
|
PROJECTS_EDIT,
|
|
canDeleteWorkspaceResource,
|
|
canWorkspace,
|
|
} from "../lib/permissions";
|
|
|
|
export const Projects: React.FC = () => {
|
|
const { t, lang } = useTranslation();
|
|
const { user } = useAppContext();
|
|
const { activeWorkspace } = useWorkspace();
|
|
const workspaceRole = activeWorkspace?.my_role;
|
|
const canCreateProject = canWorkspace(workspaceRole, PROJECTS_CREATE);
|
|
const canEditProject = canWorkspace(workspaceRole, PROJECTS_EDIT);
|
|
const canArchiveProject = canWorkspace(workspaceRole, PROJECTS_ARCHIVE);
|
|
|
|
const [projects, setProjects] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
|
const [editingProject, setEditingProject] = useState<any | null>(null);
|
|
|
|
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<Project | null>(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 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();
|
|
|
|
window.addEventListener("project_created", handleCreated);
|
|
window.addEventListener("project_updated", handleUpdated);
|
|
|
|
return () => {
|
|
window.removeEventListener("project_created", handleCreated);
|
|
window.removeEventListener("project_updated", handleUpdated);
|
|
};
|
|
}, [activeWorkspace, currentPage, limit, search, isArchived, ordering]);
|
|
|
|
const handleDeleteClick = (project: Project) => {
|
|
setProjectToDelete(project);
|
|
};
|
|
|
|
const confirmDelete = async () => {
|
|
if (!deleteModal.project) return;
|
|
try {
|
|
const deletedId = deleteModal.project.id;
|
|
await deleteProject(deletedId);
|
|
|
|
fetchProjectList();
|
|
|
|
window.dispatchEvent(new CustomEvent('project_deleted', {
|
|
detail: { id: deletedId }
|
|
}));
|
|
|
|
toast.success(t.projects?.deleteSuccess || 'Project deleted successfully');
|
|
setDeleteModal({ isOpen: false, project: null });
|
|
setDeleteInput('');
|
|
} catch (error) {
|
|
toast.error(t.projects?.deleteError || 'Failed to delete project');
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateStr: string | undefined) => {
|
|
if (!dateStr) return "-"
|
|
try {
|
|
const date = new Date(dateStr)
|
|
return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR" : "en-US", {
|
|
dateStyle: "long",
|
|
timeZone: "Asia/Tehran",
|
|
}).format(date)
|
|
} catch {
|
|
return dateStr
|
|
}
|
|
}
|
|
|
|
|
|
return (
|
|
<div className="flex flex-col p-6 min-h-[calc(100vh-73px)] bg-slate-50 dark:bg-slate-900">
|
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.projects?.title || 'Projects'}</h1>
|
|
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.projects?.description(activeWorkspace?.name || "-") || 'Manage your projects'}</p>
|
|
</div>
|
|
<div className="flex items-center gap-3 w-full sm:w-auto">
|
|
{canArchiveProject && (
|
|
<Button
|
|
variant={isArchived ? "default" : "secondary"}
|
|
onClick={() => setIsArchived(!isArchived)}
|
|
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')}
|
|
</Button>
|
|
)}
|
|
{canCreateProject && (
|
|
<Button
|
|
onClick={() => setIsCreateModalOpen(true)}
|
|
size="icon"
|
|
className="shadow-sm"
|
|
title={t.projects?.createNew || 'Create New'}
|
|
>
|
|
<Plus className="h-5 w-5" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<FilterBar
|
|
searchQuery={search}
|
|
setSearchQuery={setSearch}
|
|
ordering={ordering}
|
|
setOrdering={setOrdering}
|
|
orderingOptions={orderingOptions}
|
|
searchPlaceholder={t.projects?.searchPlaceholder || 'Search projects...'}
|
|
/>
|
|
|
|
{loading ? (
|
|
<div className="p-12 flex justify-center text-slate-500">
|
|
<div className="animate-pulse">{t.projects?.loading || 'Loading...'}</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col flex-1">
|
|
<Card className="overflow-hidden dark:bg-slate-800 dark:border-slate-700 mb-6">
|
|
<div className="p-0">
|
|
{projects.length === 0 ? (
|
|
<div className="py-16 flex flex-col items-center justify-center">
|
|
<p className="text-slate-500 dark:text-slate-400 font-medium">{t.projects?.emptyState || 'No projects found'}</p>
|
|
</div>
|
|
) : (
|
|
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
|
|
{projects.map((project) => {
|
|
const canDeleteProject = canDeleteWorkspaceResource({
|
|
workspaceRole,
|
|
currentUserId: user?.id,
|
|
createdById: project.created_by?.id,
|
|
});
|
|
return (
|
|
<li
|
|
key={project.id}
|
|
className="p-4 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors flex items-center justify-between gap-4"
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<h4 className="font-medium text-slate-900 dark:text-white truncate">{project.name}</h4>
|
|
<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>
|
|
{project.description && (
|
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1 truncate">
|
|
{project.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{(canEditProject || canDeleteProject) && (
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{canEditProject && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setEditingProject(project)}
|
|
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"
|
|
title={t.actions?.edit || "Edit"}
|
|
>
|
|
<Pencil className="w-4 h-4" />
|
|
</Button>
|
|
)}
|
|
|
|
{canDeleteProject && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setDeleteModal({ isOpen: true, project })}
|
|
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"
|
|
title={t.actions?.delete || "Delete"}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
<Pagination
|
|
currentPage={currentPage}
|
|
totalCount={totalItems}
|
|
limit={limit}
|
|
onPageChange={setCurrentPage}
|
|
onLimitChange={setLimit}
|
|
pageSizeOptions={[10, 20, 50]}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modals */}
|
|
{canCreateProject && isCreateModalOpen && (
|
|
<ProjectCreateModal
|
|
isOpen={isCreateModalOpen}
|
|
onClose={() => setIsCreateModalOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
{canEditProject && editingProject && (
|
|
<ProjectEditModal
|
|
project={editingProject}
|
|
isOpen={!!editingProject}
|
|
onClose={() => setEditingProject(null)}
|
|
/>
|
|
)}
|
|
|
|
{deleteModal.project && (
|
|
<Modal
|
|
isOpen={deleteModal.isOpen}
|
|
onClose={() => {
|
|
setDeleteModal({ isOpen: false, project: null });
|
|
setDeleteInput('');
|
|
}}
|
|
title={t.projects?.deleteTitle || 'Delete Project'}
|
|
maxWidth="max-w-md"
|
|
footer={
|
|
<>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => {
|
|
setDeleteModal({ isOpen: false, project: null });
|
|
setDeleteInput('');
|
|
}}
|
|
className="rounded-xl font-semibold"
|
|
>
|
|
{t.actions?.cancel || 'Cancel'}
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
disabled={deleteInput !== deleteModal.project.name}
|
|
onClick={confirmDelete}
|
|
className="rounded-xl font-semibold"
|
|
>
|
|
{t.actions?.delete || 'Delete'}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="flex flex-col gap-4">
|
|
<p className="text-slate-600 dark:text-slate-400 text-sm leading-relaxed">
|
|
{t.projects?.deleteWarning || 'To confirm deletion, please type the project name:'} <strong className="text-slate-900 dark:text-white select-all">{deleteModal.project.name}</strong>
|
|
</p>
|
|
|
|
<Input
|
|
type="text"
|
|
value={deleteInput}
|
|
onChange={(e) => setDeleteInput(e.target.value)}
|
|
placeholder={deleteModal.project.name}
|
|
/>
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
|
|
</div>
|
|
);
|
|
|
|
};
|