refactor(lists): align client and project page controls

This commit is contained in:
2026-04-27 20:52:18 +03:30
parent 8ecf317700
commit 1e5f0b6b5e
3 changed files with 211 additions and 217 deletions

View File

@@ -1,13 +1,13 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Plus, Building2, Loader2, Pencil, Trash2 } from "lucide-react" import { Plus, Building2, Loader2, Pencil, Trash2 } from "lucide-react"
import { useWorkspace } from "../context/WorkspaceContext" import { useWorkspace } from "../context/WorkspaceContext"
import { useTranslation } from "../hooks/useTranslation" import { useTranslation } from "../hooks/useTranslation"
import { import {
CLIENTS_CREATE, CLIENTS_CREATE,
CLIENTS_DELETE, CLIENTS_DELETE,
CLIENTS_EDIT, CLIENTS_EDIT,
canWorkspace, canWorkspace,
} from "../lib/permissions" } from "../lib/permissions"
import { type Client } from "../types/client" import { type Client } from "../types/client"
import { getClients } from "../api/clients" import { getClients } from "../api/clients"
import CreateClientModal from "../components/CreateClientModal" import CreateClientModal from "../components/CreateClientModal"
@@ -38,12 +38,12 @@ export default function Clients() {
const [editClient, setEditClient] = useState<Client | null>(null) const [editClient, setEditClient] = useState<Client | null>(null)
const [deleteClient, setDeleteClient] = useState<Client | null>(null) const [deleteClient, setDeleteClient] = useState<Client | null>(null)
const { t, lang } = useTranslation() const { t, lang } = useTranslation()
const isFa = lang === "fa" const isFa = lang === "fa"
const workspaceRole = activeWorkspace?.my_role const workspaceRole = activeWorkspace?.my_role
const canCreateClient = canWorkspace(workspaceRole, CLIENTS_CREATE) const canCreateClient = canWorkspace(workspaceRole, CLIENTS_CREATE)
const canEditClient = canWorkspace(workspaceRole, CLIENTS_EDIT) const canEditClient = canWorkspace(workspaceRole, CLIENTS_EDIT)
const canDeleteClient = canWorkspace(workspaceRole, CLIENTS_DELETE) const canDeleteClient = canWorkspace(workspaceRole, CLIENTS_DELETE)
const orderingOptions = [ const orderingOptions = [
{ value: "-created_at", label: t.ordering?.createdAtDesc || "Newest First" }, { value: "-created_at", label: t.ordering?.createdAtDesc || "Newest First" },
@@ -116,24 +116,24 @@ export default function Clients() {
return ( 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 p-6 min-h-[calc(100vh-73px)] bg-slate-50 dark:bg-slate-900">
<div className="flex justify-between items-center mb-8 gap-4"> <div className="flex justify-between items-center mb-8 gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.clients.title}</h1> <h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.clients.title}</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1"> <p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
{t.clients.description(activeWorkspace.name)} {t.clients.description(activeWorkspace.name)}
</p> </p>
</div> </div>
{canCreateClient && ( {canCreateClient && (
<Button <Button
onClick={() => setIsCreateModalOpen(true)} onClick={() => setIsCreateModalOpen(true)}
size="icon" size="icon"
className="shadow-sm shrink-0" className="shadow-sm shrink-0"
title={t.clients.addClient} title={t.clients.addClient}
> >
<Plus className="w-4 h-4" /> <Plus className="w-5 h-5" />
</Button> </Button>
)} )}
</div> </div>
<FilterBar <FilterBar
@@ -170,35 +170,32 @@ export default function Clients() {
{client.notes} {client.notes}
</p> </p>
)} )}
<div className="text-[11px] text-slate-400 mt-3 font-medium">
{t.clients.addedOn}: {formatDate(client.created_at)}
</div>
</div> </div>
{(canEditClient || canDeleteClient) && ( {(canEditClient || canDeleteClient) && (
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
{canEditClient && ( {canEditClient && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setEditClient(client)} onClick={() => setEditClient(client)}
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" 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"
> >
<Pencil className="w-4 h-4" /> <Pencil className="w-4 h-4" />
</Button> </Button>
)} )}
{canDeleteClient && ( {canDeleteClient && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setDeleteClient(client)} onClick={() => setDeleteClient(client)}
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="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"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</Button> </Button>
)} )}
</div> </div>
)} )}
</li> </li>
))} ))}
</ul> </ul>
@@ -216,32 +213,32 @@ export default function Clients() {
/> />
)} )}
{canCreateClient && ( {canCreateClient && (
<CreateClientModal <CreateClientModal
isOpen={isCreateModalOpen} isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)} onClose={() => setIsCreateModalOpen(false)}
onSuccess={fetchClientsList} onSuccess={fetchClientsList}
workspaceId={activeWorkspace.id} workspaceId={activeWorkspace.id}
/> />
)} )}
{canEditClient && ( {canEditClient && (
<EditClientModal <EditClientModal
isOpen={!!editClient} isOpen={!!editClient}
onClose={() => setEditClient(null)} onClose={() => setEditClient(null)}
onSuccess={fetchClientsList} onSuccess={fetchClientsList}
client={editClient} client={editClient}
/> />
)} )}
{canDeleteClient && ( {canDeleteClient && (
<DeleteClientModal <DeleteClientModal
isOpen={!!deleteClient} isOpen={!!deleteClient}
onClose={() => setDeleteClient(null)} onClose={() => setDeleteClient(null)}
onSuccess={fetchClientsList} onSuccess={fetchClientsList}
client={deleteClient} client={deleteClient}
/> />
)} )}
</div> </div>
) )
} }

View File

@@ -9,26 +9,26 @@ import { Plus, Archive, Trash2, Pencil } 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 } 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,
PROJECTS_DELETE, PROJECTS_DELETE,
PROJECTS_EDIT, PROJECTS_EDIT,
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 { activeWorkspace } = useWorkspace(); const { activeWorkspace } = useWorkspace();
const workspaceRole = activeWorkspace?.my_role; const workspaceRole = activeWorkspace?.my_role;
const canCreateProject = canWorkspace(workspaceRole, PROJECTS_CREATE); const canCreateProject = canWorkspace(workspaceRole, PROJECTS_CREATE);
const canEditProject = canWorkspace(workspaceRole, PROJECTS_EDIT); const canEditProject = canWorkspace(workspaceRole, PROJECTS_EDIT);
const canDeleteProject = canWorkspace(workspaceRole, PROJECTS_DELETE); const canDeleteProject = canWorkspace(workspaceRole, PROJECTS_DELETE);
const canArchiveProject = canWorkspace(workspaceRole, PROJECTS_ARCHIVE); const canArchiveProject = canWorkspace(workspaceRole, PROJECTS_ARCHIVE);
const [projects, setProjects] = useState<any[]>([]); const [projects, setProjects] = useState<any[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -100,7 +100,7 @@ export const Projects: React.FC = () => {
setProjectToDelete(project); setProjectToDelete(project);
}; };
const confirmDelete = async () => { const confirmDelete = async () => {
if (!deleteModal.project) return; if (!deleteModal.project) return;
try { try {
const deletedId = deleteModal.project.id; const deletedId = deleteModal.project.id;
@@ -118,20 +118,20 @@ export const Projects: React.FC = () => {
} catch (error) { } catch (error) {
toast.error(t.projects?.deleteError || 'Failed to delete project'); toast.error(t.projects?.deleteError || 'Failed to delete project');
} }
}; };
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)
return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR" : "en-US", { return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR" : "en-US", {
dateStyle: "long", dateStyle: "long",
timeZone: "Asia/Tehran", timeZone: "Asia/Tehran",
}).format(date) }).format(date)
} catch { } catch {
return dateStr return dateStr
} }
} }
return ( return (
@@ -142,26 +142,26 @@ export const Projects: React.FC = () => {
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.projects?.description(activeWorkspace?.name || "-") || 'Manage your projects'}</p> <p className="text-slate-500 dark:text-slate-400 mt-1">{t.projects?.description(activeWorkspace?.name || "-") || 'Manage your projects'}</p>
</div> </div>
<div className="flex items-center gap-3 w-full sm:w-auto"> <div className="flex items-center gap-3 w-full sm:w-auto">
{canArchiveProject && ( {canArchiveProject && (
<Button <Button
variant={isArchived ? "default" : "secondary"} variant={isArchived ? "default" : "secondary"}
onClick={() => setIsArchived(!isArchived)} onClick={() => setIsArchived(!isArchived)}
className="gap-2 shadow-sm flex-1 sm:flex-none" className="gap-2 shadow-sm flex-1 sm:flex-none"
> >
<Archive className="h-4 w-4" /> <Archive className="h-4 w-4" />
{isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')} {isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')}
</Button> </Button>
)} )}
{canCreateProject && ( {canCreateProject && (
<Button <Button
onClick={() => setIsCreateModalOpen(true)} onClick={() => setIsCreateModalOpen(true)}
size="icon" size="icon"
className="shadow-sm" className="shadow-sm"
title={t.projects?.createNew || 'Create New'} title={t.projects?.createNew || 'Create New'}
> >
<Plus className="h-5 w-5" /> <Plus className="h-5 w-5" />
</Button> </Button>
)} )}
</div> </div>
</div> </div>
@@ -174,76 +174,73 @@ export const Projects: React.FC = () => {
searchPlaceholder={t.projects?.searchPlaceholder || 'Search projects...'} searchPlaceholder={t.projects?.searchPlaceholder || 'Search projects...'}
/> />
{loading ? ( {loading ? (
<div className="p-12 flex justify-center text-slate-500"> <div className="p-12 flex justify-center text-slate-500">
<div className="animate-pulse">{t.projects?.loading || 'Loading...'}</div> <div className="animate-pulse">{t.projects?.loading || 'Loading...'}</div>
</div> </div>
) : ( ) : (
<div className="flex flex-col flex-1"> <div className="flex flex-col flex-1">
<Card className="overflow-hidden dark:bg-slate-800 dark:border-slate-700 mb-6"> <Card className="overflow-hidden dark:bg-slate-800 dark:border-slate-700 mb-6">
<div className="p-0"> <div className="p-0">
{projects.length === 0 ? ( {projects.length === 0 ? (
<div className="py-16 flex flex-col items-center justify-center"> <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> <p className="text-slate-500 dark:text-slate-400 font-medium">{t.projects?.emptyState || 'No projects found'}</p>
</div> </div>
) : ( ) : (
<ul className="divide-y divide-slate-200 dark:divide-slate-800"> <ul className="divide-y divide-slate-200 dark:divide-slate-800">
{projects.map((project) => ( {projects.map((project) => (
<li <li
key={project.id} 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" 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"> <div className="flex-1 min-w-0">
<h4 className="font-medium text-slate-900 dark:text-white truncate">{project.name}</h4> <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"> <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"} {project.client ? `${t.projects?.client || "Client"}: ${project.client.name}` : t.projects?.noClient || "No client"}
</p> </p>
{project.description && ( {project.description && (
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1 truncate"> <p className="text-sm text-slate-500 dark:text-slate-400 mt-1 truncate">
{project.description} {project.description}
</p> </p>
)} )}
<div className="text-[11px] text-slate-400 mt-3 font-medium"> </div>
{(t.projects as any)?.addedOn || "Added on"}: {formatDate(project.created_at)}
</div> {(canEditProject || canDeleteProject) && (
</div> <div className="flex items-center gap-1 shrink-0">
{canEditProject && (
{(canEditProject || canDeleteProject) && ( <Button
<div className="flex items-center gap-1 shrink-0"> variant="ghost"
{canEditProject && ( size="icon"
<Button onClick={() => setEditingProject(project)}
variant="ghost" 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"
size="icon" title={t.actions?.edit || "Edit"}
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" <Pencil className="w-4 h-4" />
title={t.actions?.edit || "Edit"} </Button>
> )}
<Pencil className="w-4 h-4" />
</Button> {canDeleteProject && (
)} <Button
variant="ghost"
{canDeleteProject && ( size="icon"
<Button onClick={() => setDeleteModal({ isOpen: true, project })}
variant="ghost" 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"
size="icon" title={t.actions?.delete || "Delete"}
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" <Trash2 className="w-4 h-4" />
title={t.actions?.delete || "Delete"} </Button>
> )}
<Trash2 className="w-4 h-4" /> </div>
</Button> )}
)} </li>
</div> ))}
)} </ul>
</li> )}
))} </div>
</ul> </Card>
)}
</div> <Pagination
</Card> currentPage={currentPage}
<Pagination
currentPage={currentPage}
totalCount={totalItems} totalCount={totalItems}
limit={limit} limit={limit}
onPageChange={setCurrentPage} onPageChange={setCurrentPage}
@@ -254,15 +251,15 @@ export const Projects: React.FC = () => {
)} )}
{/* Modals */} {/* Modals */}
{canCreateProject && isCreateModalOpen && ( {canCreateProject && isCreateModalOpen && (
<ProjectCreateModal <ProjectCreateModal
isOpen={isCreateModalOpen} isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)} onClose={() => setIsCreateModalOpen(false)}
/> />
)} )}
{canEditProject && editingProject && ( {canEditProject && editingProject && (
<ProjectEditModal <ProjectEditModal
project={editingProject} project={editingProject}
isOpen={!!editingProject} isOpen={!!editingProject}
onClose={() => setEditingProject(null)} onClose={() => setEditingProject(null)}

View File

@@ -153,7 +153,7 @@ export default function Tags() {
</div> </div>
{canCreateTag && ( {canCreateTag && (
<Button onClick={openCreateModal} size="icon" className="shadow-sm shrink-0" title={t.tags?.create || "Create Tag"}> <Button onClick={openCreateModal} size="icon" className="shadow-sm shrink-0" title={t.tags?.create || "Create Tag"}>
<Plus className="h-4 w-4" /> <Plus className="h-5 w-5" />
</Button> </Button>
)} )}
</div> </div>