461 lines
16 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|