Files
qlockify-frontend-deployment/src/pages/Clients.tsx

289 lines
11 KiB
TypeScript

import { useEffect, useState } from "react"
import { useSearchParams } from "react-router-dom"
import { Plus, Building2, Pencil, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { useWorkspace } from "../context/WorkspaceContext"
import { useAppContext } from "../context/AppContext"
import { useTranslation } from "../hooks/useTranslation"
import {
CLIENTS_CREATE,
CLIENTS_EDIT,
canDeleteWorkspaceResource,
canWorkspace,
} from "../lib/permissions"
import { type Client } from "../types/client"
import { getClients } from "../api/clients"
import CreateClientModal from "../components/CreateClientModal"
import EditClientModal from "../components/EditClientModal"
import DeleteClientModal from "../components/DeleteClientModal"
import EmptyStateCard from "../components/EmptyStateCard"
import FilterBar from "../components/FilterBar"
import { ListPageSkeleton } from "../components/ListPageSkeleton"
import { Button } from "../components/ui/button"
import { Card, CardContent, CardTitle } from "../components/ui/card"
import { Pagination } from "../components/Pagination"
import { readNumberParam, readStringParam, updateQueryParams } from "../lib/queryParams"
export default function Clients() {
const { activeWorkspace } = useWorkspace()
const { user } = useAppContext()
const [clients, setClients] = useState<Client[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchParams, setSearchParams] = useSearchParams()
const [totalItems, setTotalItems] = useState(0)
const [debouncedSearch, setDebouncedSearch] = useState("")
const searchQuery = readStringParam(searchParams, "search", "")
const ordering = readStringParam(searchParams, "ordering", "-created_at")
const currentPage = Math.max(1, readNumberParam(searchParams, "page", 1))
const limit = Math.max(1, readNumberParam(searchParams, "limit", 10))
// Modal States
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [editClient, setEditClient] = useState<Client | null>(null)
const [deleteClient, setDeleteClient] = useState<Client | null>(null)
const { t, lang } = useTranslation()
const isFa = lang === "fa"
const workspaceRole = activeWorkspace?.my_role
const canCreateClient = canWorkspace(workspaceRole, CLIENTS_CREATE)
const canEditClient = canWorkspace(workspaceRole, CLIENTS_EDIT)
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)" },
{ value: "-updated_at", label: t.ordering?.updatedAtDesc || "Recently Updated" },
]
// Debounce search input to avoid spamming the API
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearch(searchQuery)
}, 500)
return () => clearTimeout(handler)
}, [searchQuery])
const fetchClientsList = async () => {
if (!activeWorkspace?.id) {
setIsLoading(false)
return
}
setIsLoading(true)
try {
const offset = (currentPage - 1) * limit
const data: any = await getClients(activeWorkspace.id, debouncedSearch, ordering, limit, offset)
const items = data?.results || (Array.isArray(data) ? data : [])
const count = data?.count !== undefined ? data.count : items.length
setClients(items)
setTotalItems(count)
} catch (error) {
console.error(t.clients.errors.fetchFailed, error)
toast.error(t.clients.errors.fetchFailed)
setClients([])
} finally {
setIsLoading(false)
}
}
const formatDate = (dateStr: string | undefined) => {
if (!dateStr) return "-"
try {
const date = new Date(dateStr)
return new Intl.DateTimeFormat(isFa ? "fa-IR" : "en-US", {
dateStyle: "long",
timeZone: "Asia/Tehran"
}).format(date)
} catch (e) {
return dateStr
}
}
useEffect(() => {
fetchClientsList()
}, [activeWorkspace?.id, debouncedSearch, ordering, currentPage, limit])
const updateListParams = (updates: Record<string, string | number | null | undefined>) => {
setSearchParams(
(current) =>
updateQueryParams(current, updates, {
search: "",
ordering: "-created_at",
page: 1,
limit: 10,
}),
{ replace: true },
)
}
if (!activeWorkspace) {
return (
<div className="mx-auto max-w-7xl p-4 md:p-6">
<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">
{t.clients.selectWorkspace}
</div>
</div>
)
}
return (
<div className="mx-auto flex min-h-full max-w-7xl flex-col p-4 md:p-6">
<div className="flex flex-1 flex-col gap-5">
<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 className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.clients.title}</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{t.clients.description(activeWorkspace.name)}
</p>
</div>
{canCreateClient && (
<Button
onClick={() => setIsCreateModalOpen(true)}
size="icon"
className="shrink-0 shadow-sm"
title={t.clients.addClient}
>
<Plus className="h-5 w-5" />
</Button>
)}
</div>
</div>
<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">
<FilterBar
searchQuery={searchQuery}
setSearchQuery={(value) => updateListParams({ search: value, page: 1 })}
ordering={ordering}
setOrdering={(value) => updateListParams({ ordering: value, page: 1 })}
orderingOptions={orderingOptions}
searchPlaceholder={t.clients.searchPlaceholder}
/>
</div>
{isLoading ? (
<ListPageSkeleton variant="standard-grid" />
) : (
<div className="flex flex-1 flex-col gap-6">
{clients.length === 0 ? (
<EmptyStateCard
icon={Building2}
title={t.clients.noClients}
description={searchQuery ? t.clients.noClientsSearch : t.clients.noClientsAdd}
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 2xl:grid-cols-3">
{clients.map((client) => {
const canDeleteClient = canDeleteWorkspaceResource({
workspaceRole,
currentUserId: user?.id,
createdById: client.created_by?.id,
})
return (
<Card key={client.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="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-sm font-semibold text-slate-700 dark:bg-slate-700 dark:text-slate-200">
{client.thumbnail ? (
<img src={client.thumbnail} alt={client.name} className="h-full w-full rounded-xl object-cover" />
) : (
client.name.trim().charAt(0).toUpperCase() || "C"
)}
</div>
<div className="min-w-0">
<CardTitle className="truncate text-base text-slate-900 dark:text-white">{client.name}</CardTitle>
</div>
</div>
{(canEditClient || canDeleteClient) && (
<div className="flex shrink-0 items-center gap-1">
{canEditClient && (
<Button
variant="ghost"
size="icon"
onClick={() => setEditClient(client)}
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>
)}
{canDeleteClient && (
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteClient(client)}
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">
{client.notes || t.workspace?.noDescription || "No description"}
</p>
<div className="text-xs font-medium uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
{t.clients.addedOn}: {formatDate(client.created_at)}
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
{clients.length > 0 && (
<Pagination
currentPage={currentPage}
totalCount={totalItems}
limit={limit}
onPageChange={(page) => updateListParams({ page })}
onLimitChange={(pageLimit) => updateListParams({ limit: pageLimit, page: 1 })}
/>
)}
</div>
)}
</div>
{canCreateClient && (
<CreateClientModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={fetchClientsList}
workspaceId={activeWorkspace.id}
/>
)}
{canEditClient && (
<EditClientModal
isOpen={!!editClient}
onClose={() => setEditClient(null)}
onSuccess={fetchClientsList}
client={editClient}
/>
)}
{!!deleteClient && (
<DeleteClientModal
isOpen={!!deleteClient}
onClose={() => setDeleteClient(null)}
onSuccess={fetchClientsList}
client={deleteClient}
/>
)}
</div>
)
}