Restore client user editing sheet and tweak pagination copy

This commit is contained in:
Esdras Renan 2025-10-24 09:50:40 -03:00
parent c7aaa60d9a
commit b51d0770d3
4 changed files with 333 additions and 6 deletions

View file

@ -1320,7 +1320,7 @@ async function handleDeleteUser() {
<div className="flex flex-col items-center justify-between gap-3 border-t border-slate-200 pt-2 text-xs text-neutral-500 md:flex-row md:text-sm"> <div className="flex flex-col items-center justify-between gap-3 border-t border-slate-200 pt-2 text-xs text-neutral-500 md:flex-row md:text-sm">
<div>{teamTotal === 0 ? "Nenhum registro" : `Mostrando ${teamStart}-${teamEnd} de ${teamTotal}`}</div> <div>{teamTotal === 0 ? "Nenhum registro" : `Mostrando ${teamStart}-${teamEnd} de ${teamTotal}`}</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-xs uppercase tracking-wide text-neutral-500 md:text-sm"> <div className="flex items-center gap-2 text-xs tracking-wide text-neutral-500 md:text-sm">
<span>Itens por página</span> <span>Itens por página</span>
<Select <Select
value={`${teamPageSize}`} value={`${teamPageSize}`}
@ -1629,7 +1629,7 @@ async function handleDeleteUser() {
<div className="flex flex-col items-center justify-between gap-3 border-t border-slate-200 pt-2 text-xs text-neutral-500 md:flex-row md:text-sm"> <div className="flex flex-col items-center justify-between gap-3 border-t border-slate-200 pt-2 text-xs text-neutral-500 md:flex-row md:text-sm">
<div>{usersTotal === 0 ? "Nenhum registro" : `Mostrando ${usersStart}-${usersEnd} de ${usersTotal}`}</div> <div>{usersTotal === 0 ? "Nenhum registro" : `Mostrando ${usersStart}-${usersEnd} de ${usersTotal}`}</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-xs uppercase tracking-wide text-neutral-500 md:text-sm"> <div className="flex items-center gap-2 text-xs tracking-wide text-neutral-500 md:text-sm">
<span>Itens por página</span> <span>Itens por página</span>
<Select <Select
value={`${usersPageSize}`} value={`${usersPageSize}`}
@ -1887,7 +1887,7 @@ async function handleDeleteUser() {
<IconTrash className="size-4" /> Revogar selecionados <IconTrash className="size-4" /> Revogar selecionados
</Button> </Button>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-xs uppercase tracking-wide text-neutral-500 md:text-sm"> <div className="flex items-center gap-2 text-xs tracking-wide text-neutral-500 md:text-sm">
<span>Itens por página</span> <span>Itens por página</span>
<Select <Select
value={`${invitesPageSize}`} value={`${invitesPageSize}`}

View file

@ -895,7 +895,7 @@ function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableVi
{total === 0 ? "Nenhum registro" : `Mostrando ${start}-${end} de ${total}`} {total === 0 ? "Nenhum registro" : `Mostrando ${start}-${end} de ${total}`}
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-xs uppercase tracking-wide text-neutral-500 md:text-sm"> <div className="flex items-center gap-2 text-xs tracking-wide text-neutral-500 md:text-sm">
<span>Itens por página</span> <span>Itens por página</span>
<Select value={`${pageSize}`} onValueChange={(v) => { setPageSize(Number(v)); setPageIndex(0) }}> <Select value={`${pageSize}`} onValueChange={(v) => { setPageSize(Number(v)); setPageIndex(0) }}>
<SelectTrigger className="h-8 w-20"> <SelectTrigger className="h-8 w-20">

View file

@ -1,6 +1,6 @@
"use client" "use client"
import { useCallback, useMemo, useState, useTransition } from "react" import { useCallback, useEffect, useMemo, useState, useTransition } from "react"
import { format } from "date-fns" import { format } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { Controller, FormProvider, useFieldArray, useForm } from "react-hook-form" import { Controller, FormProvider, useFieldArray, useForm } from "react-hook-form"
@ -32,6 +32,7 @@ import {
} from "@/lib/schemas/company" } from "@/lib/schemas/company"
import type { NormalizedCompany } from "@/server/company-service" import type { NormalizedCompany } from "@/server/company-service"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import type { RoleOption } from "@/lib/authz"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@ -91,6 +92,18 @@ const ROLE_LABEL: Record<AdminAccount["role"], string> = {
COLLABORATOR: "Colaborador", COLLABORATOR: "Colaborador",
} }
const ROLE_TO_OPTION: Record<AdminAccount["role"], RoleOption> = {
MANAGER: "manager",
COLLABORATOR: "collaborator",
}
const ROLE_OPTIONS_DISPLAY: ReadonlyArray<{ value: AdminAccount["role"]; label: string }> = [
{ value: "MANAGER", label: "Gestor" },
{ value: "COLLABORATOR", label: "Colaborador" },
]
const NO_COMPANY_SELECT_VALUE = "__none__"
const NO_CONTACT_VALUE = "__none__" const NO_CONTACT_VALUE = "__none__"
function createId(prefix: string) { function createId(prefix: string) {
@ -137,6 +150,17 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({}) const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
const [deleteDialogIds, setDeleteDialogIds] = useState<string[]>([]) const [deleteDialogIds, setDeleteDialogIds] = useState<string[]>([])
const [editAccountId, setEditAccountId] = useState<string | null>(null)
const [editAuthUserId, setEditAuthUserId] = useState<string | null>(null)
const [editForm, setEditForm] = useState({
name: "",
email: "",
role: "COLLABORATOR" as AdminAccount["role"],
companyId: "",
})
const [isSavingAccount, setIsSavingAccount] = useState(false)
const [isResettingPassword, setIsResettingPassword] = useState(false)
const [passwordPreview, setPasswordPreview] = useState<string | null>(null)
const filteredAccounts = useMemo(() => { const filteredAccounts = useMemo(() => {
const term = search.trim().toLowerCase() const term = search.trim().toLowerCase()
@ -157,6 +181,11 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
[rowSelection] [rowSelection]
) )
const editAccount = useMemo(
() => (editAccountId ? accounts.find((account) => account.id === editAccountId) ?? null : null),
[accounts, editAccountId]
)
const companies = useMemo(() => { const companies = useMemo(() => {
const map = new Map<string, string>() const map = new Map<string, string>()
accounts.forEach((account) => { accounts.forEach((account) => {
@ -173,6 +202,131 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
const closeDeleteDialog = useCallback(() => setDeleteDialogIds([]), []) const closeDeleteDialog = useCallback(() => setDeleteDialogIds([]), [])
const closeEditor = useCallback(() => {
setEditAccountId(null)
setEditAuthUserId(null)
setPasswordPreview(null)
}, [])
useEffect(() => {
if (editAccount) {
setEditForm({
name: editAccount.name,
email: editAccount.email,
role: editAccount.role,
companyId: editAccount.companyId ?? "",
})
}
}, [editAccount])
const refreshAccounts = useCallback(async () => {
try {
const response = await fetch("/api/admin/users", { credentials: "include", cache: "no-store" })
if (!response.ok) {
const payload = await response.json().catch(() => null)
throw new Error(payload?.error ?? "Não foi possível atualizar a lista de usuários.")
}
const data = (await response.json()) as { items: Array<AdminAccount & { name: string | null }> }
setAccounts(
data.items.map((item) => ({
...item,
name: item.name ?? item.email,
})),
)
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao atualizar usuários."
toast.error(message)
}
}, [])
const handleOpenEditor = useCallback((account: AdminAccount) => {
if (!account.authUserId) {
toast.error("Não foi possível localizar o perfil de acesso para este usuário.")
return
}
setEditAccountId(account.id)
setEditAuthUserId(account.authUserId)
setPasswordPreview(null)
}, [])
const handleSaveAccount = useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!editAccount || !editAuthUserId) {
toast.error("Nenhum usuário selecionado.")
return
}
const payload = {
name: editForm.name.trim(),
email: editForm.email.trim().toLowerCase(),
role: ROLE_TO_OPTION[editForm.role] ?? "collaborator",
tenantId: editAccount.tenantId,
companyId: editForm.companyId ? editForm.companyId : null,
}
if (!payload.name) {
toast.error("Informe o nome do usuário.")
return
}
if (!payload.email || !payload.email.includes("@")) {
toast.error("Informe um e-mail válido.")
return
}
setIsSavingAccount(true)
try {
const response = await fetch(`/api/admin/users/${editAuthUserId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload),
})
if (!response.ok) {
const data = await response.json().catch(() => null)
throw new Error(data?.error ?? "Não foi possível salvar as alterações.")
}
await refreshAccounts()
toast.success("Usuário atualizado com sucesso.")
closeEditor()
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao salvar usuário."
toast.error(message)
} finally {
setIsSavingAccount(false)
}
},
[editAccount, editAuthUserId, editForm, refreshAccounts, closeEditor]
)
const handleResetPassword = useCallback(async () => {
if (!editAuthUserId) {
toast.error("Usuário inválido para gerar senha.")
return
}
setIsResettingPassword(true)
toast.loading("Gerando nova senha...", { id: "admin-users-reset-password" })
try {
const response = await fetch(`/api/admin/users/${editAuthUserId}/reset-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
})
if (!response.ok) {
const data = await response.json().catch(() => null)
throw new Error(data?.error ?? "Falha ao gerar senha temporária.")
}
const data = (await response.json()) as { temporaryPassword: string }
setPasswordPreview(data.temporaryPassword)
toast.success("Senha temporária criada com sucesso.", { id: "admin-users-reset-password" })
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao gerar senha."
toast.error(message, { id: "admin-users-reset-password" })
} finally {
setIsResettingPassword(false)
}
}, [editAuthUserId])
const handleDelete = useCallback( const handleDelete = useCallback(
(ids: string[]) => { (ids: string[]) => {
if (ids.length === 0) return if (ids.length === 0) return
@ -274,6 +428,7 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
<TableHead>Empresa</TableHead> <TableHead>Empresa</TableHead>
<TableHead>Papel</TableHead> <TableHead>Papel</TableHead>
<TableHead>Último acesso</TableHead> <TableHead>Último acesso</TableHead>
<TableHead className="text-right">Ações</TableHead>
<TableHead className="text-right">Selecionar</TableHead> <TableHead className="text-right">Selecionar</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@ -312,6 +467,29 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
<Badge variant="secondary">{ROLE_LABEL[account.role]}</Badge> <Badge variant="secondary">{ROLE_LABEL[account.role]}</Badge>
</TableCell> </TableCell>
<TableCell className="text-xs text-muted-foreground">{formatDate(account.lastSeenAt)}</TableCell> <TableCell className="text-xs text-muted-foreground">{formatDate(account.lastSeenAt)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
disabled={!account.authUserId || isPending}
onClick={() => handleOpenEditor(account)}
>
<IconPencil className="mr-1 size-4" />
<span className="hidden sm:inline">Editar</span>
</Button>
<Button
variant="ghost"
size="sm"
className="text-rose-600 hover:bg-rose-50 hover:text-rose-700"
disabled={isPending}
onClick={() => openDeleteDialog([account.id])}
>
<IconTrash className="mr-1 size-4" />
<span className="hidden sm:inline">Remover</span>
</Button>
</div>
</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Checkbox <Checkbox
checked={rowSelection[account.id] ?? false} checked={rowSelection[account.id] ?? false}
@ -348,6 +526,155 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Sheet open={Boolean(editAccountId)} onOpenChange={(open) => (!open ? closeEditor() : null)}>
<SheetContent side="right" className="space-y-6 overflow-y-auto px-6 pb-10 sm:max-w-lg">
<SheetHeader className="px-0 pt-6">
<SheetTitle>Editar usuário</SheetTitle>
<p className="text-sm text-muted-foreground">
Atualize os dados cadastrais, papel e vínculo do colaborador.
</p>
</SheetHeader>
{editAccount ? (
<form className="space-y-6" onSubmit={handleSaveAccount}>
<div className="rounded-lg border border-border/60 bg-muted/40 p-4">
<p className="text-sm font-semibold text-foreground">{editAccount.name}</p>
<p className="text-xs text-muted-foreground">{editAccount.email}</p>
<div className="mt-3 text-xs text-muted-foreground">
Essa seleção substitui o vínculo atual no portal do cliente.
</div>
</div>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="edit-name">Nome</Label>
<Input
id="edit-name"
value={editForm.name}
onChange={(event) => setEditForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder="Nome completo"
disabled={isSavingAccount}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-email">E-mail</Label>
<Input
id="edit-email"
type="email"
value={editForm.email}
onChange={(event) => setEditForm((prev) => ({ ...prev, email: event.target.value }))}
placeholder="usuario@empresa.com"
disabled={isSavingAccount}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-role">Papel</Label>
<Select
value={editForm.role}
onValueChange={(value) =>
setEditForm((prev) => ({ ...prev, role: value as AdminAccount["role"] }))
}
disabled={isSavingAccount}
>
<SelectTrigger id="edit-role">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
{ROLE_OPTIONS_DISPLAY.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-company">Empresa vinculada</Label>
<Select
value={editForm.companyId || NO_COMPANY_SELECT_VALUE}
onValueChange={(value) =>
setEditForm((prev) => ({
...prev,
companyId: value === NO_COMPANY_SELECT_VALUE ? "" : value,
}))
}
disabled={isSavingAccount}
>
<SelectTrigger id="edit-company">
<SelectValue placeholder="Sem empresa vinculada" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_COMPANY_SELECT_VALUE}>Sem empresa vinculada</SelectItem>
{companies.map((company) => (
<SelectItem key={company.id} value={company.id}>
{company.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="rounded-lg border border-border/60 bg-muted/20 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-foreground">Gerar nova senha</p>
<p className="text-xs text-muted-foreground">
Uma senha temporária será exibida abaixo. Compartilhe com o usuário e peça para trocá-la no primeiro acesso.
</p>
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={handleResetPassword}
disabled={isResettingPassword}
>
{isResettingPassword ? "Gerando..." : "Gerar senha"}
</Button>
</div>
{passwordPreview ? (
<div className="mt-4 flex flex-wrap items-center gap-3 rounded-md border border-dashed border-border/60 bg-background p-3">
<code className="text-sm font-semibold text-foreground">{passwordPreview}</code>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
if (!passwordPreview) return
const promise = navigator.clipboard?.writeText(passwordPreview)
if (!promise) {
toast.error("Não foi possível copiar a senha.")
return
}
promise
.then(() => toast.success("Senha copiada para a área de transferência."))
.catch(() => toast.error("Não foi possível copiar a senha."))
}}
>
Copiar
</Button>
</div>
) : null}
</div>
<SheetFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="outline" onClick={closeEditor} disabled={isSavingAccount}>
Cancelar
</Button>
<Button type="submit" disabled={isSavingAccount}>
{isSavingAccount ? "Salvando..." : "Salvar alterações"}
</Button>
</SheetFooter>
</form>
) : (
<p className="text-sm text-muted-foreground">Selecione um usuário para visualizar os detalhes.</p>
)}
</SheetContent>
</Sheet>
</Card> </Card>
) )
} }

View file

@ -102,7 +102,7 @@ export function TablePagination<TData>({
<div className="flex flex-col-reverse items-center gap-3 md:flex-row md:gap-4 md:justify-end md:flex-1"> <div className="flex flex-col-reverse items-center gap-3 md:flex-row md:gap-4 md:justify-end md:flex-1">
{pageSizeOptions.length > 0 ? ( {pageSizeOptions.length > 0 ? (
<div className="flex items-center gap-2 text-xs uppercase tracking-wide text-neutral-500 md:text-sm"> <div className="flex items-center gap-2 text-xs tracking-wide text-neutral-500 md:text-sm">
<span>{rowsPerPageLabel}</span> <span>{rowsPerPageLabel}</span>
<Select <Select
value={`${pageSize}`} value={`${pageSize}`}