Restore client user editing sheet and tweak pagination copy
This commit is contained in:
parent
c7aaa60d9a
commit
b51d0770d3
4 changed files with 333 additions and 6 deletions
|
|
@ -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>{teamTotal === 0 ? "Nenhum registro" : `Mostrando ${teamStart}-${teamEnd} de ${teamTotal}`}</div>
|
||||
<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>
|
||||
<Select
|
||||
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>{usersTotal === 0 ? "Nenhum registro" : `Mostrando ${usersStart}-${usersEnd} de ${usersTotal}`}</div>
|
||||
<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>
|
||||
<Select
|
||||
value={`${usersPageSize}`}
|
||||
|
|
@ -1887,7 +1887,7 @@ async function handleDeleteUser() {
|
|||
<IconTrash className="size-4" /> Revogar selecionados
|
||||
</Button>
|
||||
<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>
|
||||
<Select
|
||||
value={`${invitesPageSize}`}
|
||||
|
|
|
|||
|
|
@ -895,7 +895,7 @@ function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableVi
|
|||
{total === 0 ? "Nenhum registro" : `Mostrando ${start}-${end} de ${total}`}
|
||||
</div>
|
||||
<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>
|
||||
<Select value={`${pageSize}`} onValueChange={(v) => { setPageSize(Number(v)); setPageIndex(0) }}>
|
||||
<SelectTrigger className="h-8 w-20">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useMemo, useState, useTransition } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState, useTransition } from "react"
|
||||
import { format } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { Controller, FormProvider, useFieldArray, useForm } from "react-hook-form"
|
||||
|
|
@ -32,6 +32,7 @@ import {
|
|||
} from "@/lib/schemas/company"
|
||||
import type { NormalizedCompany } from "@/server/company-service"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { RoleOption } from "@/lib/authz"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
|
@ -91,6 +92,18 @@ const ROLE_LABEL: Record<AdminAccount["role"], string> = {
|
|||
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__"
|
||||
|
||||
function createId(prefix: string) {
|
||||
|
|
@ -137,6 +150,17 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
|
|||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
|
||||
const [isPending, startTransition] = useTransition()
|
||||
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 term = search.trim().toLowerCase()
|
||||
|
|
@ -157,6 +181,11 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
|
|||
[rowSelection]
|
||||
)
|
||||
|
||||
const editAccount = useMemo(
|
||||
() => (editAccountId ? accounts.find((account) => account.id === editAccountId) ?? null : null),
|
||||
[accounts, editAccountId]
|
||||
)
|
||||
|
||||
const companies = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
accounts.forEach((account) => {
|
||||
|
|
@ -173,6 +202,131 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
|
|||
|
||||
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(
|
||||
(ids: string[]) => {
|
||||
if (ids.length === 0) return
|
||||
|
|
@ -274,6 +428,7 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
|
|||
<TableHead>Empresa</TableHead>
|
||||
<TableHead>Papel</TableHead>
|
||||
<TableHead>Último acesso</TableHead>
|
||||
<TableHead className="text-right">Ações</TableHead>
|
||||
<TableHead className="text-right">Selecionar</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
|
@ -312,6 +467,29 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
|
|||
<Badge variant="secondary">{ROLE_LABEL[account.role]}</Badge>
|
||||
</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">
|
||||
<Checkbox
|
||||
checked={rowSelection[account.id] ?? false}
|
||||
|
|
@ -348,6 +526,155 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
{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>
|
||||
<Select
|
||||
value={`${pageSize}`}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue