sistema-de-chamados/src/components/admin/clients/admin-clients-manager.tsx

461 lines
16 KiB
TypeScript

"use client"
import { useCallback, useMemo, useState, useTransition } from "react"
import { format } from "date-fns"
import { ptBR } from "date-fns/locale"
import { toast } from "sonner"
import {
ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
SortingState,
} from "@tanstack/react-table"
import {
IconChevronLeft,
IconChevronRight,
IconFilter,
IconTrash,
IconUser,
} from "@tabler/icons-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
export type AdminClient = {
id: string
email: string
name: string
role: "MANAGER" | "COLLABORATOR"
companyId: string | null
companyName: string | null
tenantId: string
createdAt: string
updatedAt: string
authUserId: string | null
lastSeenAt: string | null
}
const ROLE_LABEL: Record<AdminClient["role"], string> = {
MANAGER: "Gestor",
COLLABORATOR: "Colaborador",
}
function formatDate(dateString: string) {
const date = new Date(dateString)
return format(date, "dd/MM/yy HH:mm", { locale: ptBR })
}
function formatLastSeen(lastSeen: string | null) {
if (!lastSeen) return "Nunca conectado"
return formatDate(lastSeen)
}
export function AdminClientsManager({ initialClients }: { initialClients: AdminClient[] }) {
const [clients, setClients] = useState(initialClients)
const [search, setSearch] = useState("")
const [roleFilter, setRoleFilter] = useState<"all" | AdminClient["role"]>("all")
const [companyFilter, setCompanyFilter] = useState<string>("all")
const [rowSelection, setRowSelection] = useState({})
const [sorting, setSorting] = useState<SortingState>([{ id: "name", desc: false }])
const [isPending, startTransition] = useTransition()
const [deleteDialogIds, setDeleteDialogIds] = useState<string[]>([])
const companies = useMemo(() => {
const entries = new Map<string, string>()
clients.forEach((client) => {
if (client.companyId && client.companyName) {
entries.set(client.companyId, client.companyName)
}
})
return Array.from(entries.entries()).map(([id, name]) => ({ id, name }))
}, [clients])
const filteredData = useMemo(() => {
return clients.filter((client) => {
if (roleFilter !== "all" && client.role !== roleFilter) return false
if (companyFilter !== "all" && client.companyId !== companyFilter) return false
if (!search.trim()) return true
const term = search.trim().toLowerCase()
return (
client.name.toLowerCase().includes(term) ||
client.email.toLowerCase().includes(term) ||
(client.companyName ?? "").toLowerCase().includes(term)
)
})
}, [clients, roleFilter, companyFilter, search])
const deleteTargets = useMemo(
() => clients.filter((client) => deleteDialogIds.includes(client.id)),
[clients, deleteDialogIds],
)
const handleDelete = useCallback(
(ids: string[]) => {
if (ids.length === 0) return
startTransition(async () => {
try {
const response = await fetch("/api/admin/clients", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
})
if (!response.ok) {
const payload = await response.json().catch(() => null)
throw new Error(payload?.error ?? "Não foi possível excluir os clientes selecionados.")
}
const { deletedIds } = (await response.json().catch(() => ({ deletedIds: [] }))) as {
deletedIds: string[]
}
if (deletedIds.length > 0) {
setClients((prev) => prev.filter((client) => !deletedIds.includes(client.id)))
setRowSelection({})
setDeleteDialogIds([])
}
toast.success(
deletedIds.length === 1
? "Cliente removido com sucesso."
: `${deletedIds.length} clientes removidos com sucesso.`,
)
} catch (error) {
const message =
error instanceof Error
? error.message
: "Não foi possível excluir os clientes selecionados."
toast.error(message)
}
})
},
[startTransition],
)
const columns = useMemo<ColumnDef<AdminClient>[]>(
() => [
{
id: "select",
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Selecionar todos"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Selecionar linha"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "name",
header: "Cliente",
cell: ({ row }) => {
const client = row.original
const initials = client.name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((value) => value.charAt(0).toUpperCase())
.join("")
return (
<div className="flex items-center gap-3">
<Avatar className="size-9 border border-slate-200">
<AvatarFallback>{initials || client.email.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-neutral-900">{client.name}</p>
<p className="truncate text-xs text-neutral-500">{client.email}</p>
</div>
</div>
)
},
},
{
accessorKey: "role",
header: "Perfil",
cell: ({ row }) => {
const role = row.original.role
const variant = role === "MANAGER" ? "default" : "secondary"
return <Badge variant={variant}>{ROLE_LABEL[role]}</Badge>
},
},
{
accessorKey: "companyName",
header: "Empresa",
cell: ({ row }) =>
row.original.companyName ? (
<Badge variant="outline" className="bg-slate-50 text-xs font-medium">
{row.original.companyName}
</Badge>
) : (
<span className="text-xs text-neutral-500">Sem empresa</span>
),
},
{
accessorKey: "createdAt",
header: "Cadastrado em",
cell: ({ row }) => (
<span className="text-xs text-neutral-600">{formatDate(row.original.createdAt)}</span>
),
},
{
id: "lastSeenAt",
header: "Último acesso",
cell: ({ row }) => (
<span className="text-xs text-neutral-600">{formatLastSeen(row.original.lastSeenAt)}</span>
),
},
{
id: "actions",
header: "",
enableSorting: false,
cell: ({ row }) => (
<Button
variant="outline"
size="sm"
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
disabled={isPending}
onClick={() => setDeleteDialogIds([row.original.id])}
>
<IconTrash className="mr-2 size-4" /> Remover
</Button>
),
},
],
[isPending, setDeleteDialogIds]
)
const table = useReactTable({
data: filteredData,
columns,
state: { rowSelection, sorting },
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getRowId: (row) => row.id,
initialState: {
pagination: {
pageSize: 10,
},
},
})
const selectedRows = table.getSelectedRowModel().flatRows.map((row) => row.original)
const isBulkDelete = deleteTargets.length > 1
const dialogTitle = isBulkDelete ? "Remover clientes selecionados" : "Remover cliente"
const dialogDescription = isBulkDelete
? "Essa ação remove os clientes selecionados e revoga o acesso ao portal."
: "Essa ação remove o cliente escolhido e revoga o acesso ao portal."
const previewTargets = deleteTargets.slice(0, 3)
const remainingCount = deleteTargets.length - previewTargets.length
return (
<>
<div className="space-y-4">
<div className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2 text-sm text-neutral-600">
<IconUser className="size-4" />
{clients.length} cliente{clients.length === 1 ? "" : "s"}
</div>
<div className="flex flex-col gap-2 md:flex-row md:items-center">
<div className="flex items-center gap-2">
<Input
placeholder="Buscar por nome, e-mail ou empresa"
value={search}
onChange={(event) => setSearch(event.target.value)}
className="h-9 w-full md:w-72"
/>
<Button variant="outline" size="icon" className="md:hidden">
<IconFilter className="size-4" />
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<Select value={roleFilter} onValueChange={(value) => setRoleFilter(value as typeof roleFilter)}>
<SelectTrigger className="h-9 w-40">
<SelectValue placeholder="Perfil" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os perfis</SelectItem>
<SelectItem value="MANAGER">Gestores</SelectItem>
<SelectItem value="COLLABORATOR">Colaboradores</SelectItem>
</SelectContent>
</Select>
<Select value={companyFilter} onValueChange={(value) => setCompanyFilter(value)}>
<SelectTrigger className="h-9 w-48">
<SelectValue placeholder="Empresa" />
</SelectTrigger>
<SelectContent className="max-h-64">
<SelectItem value="all">Todas as empresas</SelectItem>
{companies.map((company) => (
<SelectItem key={company.id} value={company.id}>
{company.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
className="gap-2 border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700 disabled:text-rose-300 disabled:border-rose-100"
disabled={selectedRows.length === 0 || isPending}
onClick={() => setDeleteDialogIds(selectedRows.map((row) => row.id))}
>
<IconTrash className="size-4" />
Excluir selecionados
</Button>
</div>
</div>
</div>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<Table>
<TableHeader className="bg-slate-50">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="border-slate-200">
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="text-xs uppercase tracking-wide text-neutral-500">
{header.isPlaceholder ? null : header.column.columnDef.header instanceof Function
? header.column.columnDef.header(header.getContext())
: header.column.columnDef.header}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center text-sm text-neutral-500">
Nenhum cliente encontrado para os filtros selecionados.
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="align-middle text-sm text-neutral-700">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="flex flex-col items-center justify-between gap-3 text-sm text-neutral-600 md:flex-row">
<div>
Página {table.getState().pagination.pageIndex + 1} de {table.getPageCount() || 1}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<IconChevronLeft className="size-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<IconChevronRight className="size-4" />
</Button>
</div>
</div>
</div>
<Dialog
open={deleteDialogIds.length > 0}
onOpenChange={(open) => {
if (!open && !isPending) {
setDeleteDialogIds([])
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogDescription>{dialogDescription}</DialogDescription>
</DialogHeader>
{deleteTargets.length > 0 ? (
<div className="space-y-3 py-2 text-sm text-neutral-600">
<p>
Confirme a exclusão de {isBulkDelete ? `${deleteTargets.length} clientes selecionados` : "um cliente"}. O acesso ao portal será revogado imediatamente.
</p>
<ul className="space-y-1">
{previewTargets.map((target) => (
<li key={target.id} className="rounded-md bg-slate-100 px-3 py-2 text-sm text-neutral-800">
<span className="font-medium">{target.name}</span>
<span className="text-neutral-500"> {target.email}</span>
</li>
))}
{remainingCount > 0 ? (
<li className="px-3 py-1 text-xs text-neutral-500">+ {remainingCount} outro{remainingCount === 1 ? "" : "s"}</li>
) : null}
</ul>
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogIds([])} disabled={isPending}>
Cancelar
</Button>
<Button
variant="destructive"
onClick={() => handleDelete(deleteDialogIds)}
disabled={isPending}
>
{isPending ? "Removendo..." : isBulkDelete ? "Excluir clientes" : "Excluir cliente"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}