feat: overhaul admin user management and desktop UX

This commit is contained in:
Esdras Renan 2025-10-13 10:36:38 -03:00
parent 7d6f3bea01
commit ecad81b0ea
16 changed files with 1546 additions and 395 deletions

View file

@ -1,5 +1,6 @@
"use client"
import Link from "next/link"
import { useEffect, useMemo, useState, useTransition } from "react"
import { toast } from "sonner"
@ -16,6 +17,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
@ -27,6 +29,9 @@ type AdminUser = {
tenantId: string
createdAt: string
updatedAt: string | null
companyId: string | null
companyName: string | null
machinePersona: string | null
}
type AdminInvite = {
@ -48,6 +53,11 @@ type AdminInvite = {
revokedReason: string | null
}
type CompanyOption = {
id: string
name: string
}
type Props = {
initialUsers: AdminUser[]
initialInvites: AdminInvite[]
@ -68,6 +78,11 @@ function formatRole(role: string) {
return ROLE_LABELS[key] ?? role
}
function normalizeRoleValue(role: string | null | undefined): RoleOption {
const candidate = (role ?? "agent").toLowerCase() as RoleOption
return ((ROLE_OPTIONS as readonly string[]).includes(candidate) ? candidate : "agent") as RoleOption
}
function formatTenantLabel(tenantId: string, defaultTenantId: string) {
if (!tenantId) return "Principal"
if (tenantId === defaultTenantId) return "Principal"
@ -104,46 +119,85 @@ function sanitizeInvite(invite: AdminInvite & { events?: unknown }): AdminInvite
}
export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, defaultTenantId }: Props) {
const [users] = useState<AdminUser[]>(initialUsers)
const [users, setUsers] = useState<AdminUser[]>(initialUsers)
const [invites, setInvites] = useState<AdminInvite[]>(initialInvites)
const [companies, setCompanies] = useState<CompanyOption[]>([])
const [email, setEmail] = useState("")
const [name, setName] = useState("")
const [role, setRole] = useState<RoleOption>("agent")
const [tenantId, setTenantId] = useState(defaultTenantId)
const [expiresInDays, setExpiresInDays] = useState<string>("7")
const [expiresInDays, setExpiresInDays] = useState("7")
const [lastInviteLink, setLastInviteLink] = useState<string | null>(null)
const [revokingId, setRevokingId] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
const [companies, setCompanies] = useState<Array<{ id: string; name: string }>>([])
const [linkEmail, setLinkEmail] = useState("")
const [linkCompanyId, setLinkCompanyId] = useState("")
const [assigningCompany, setAssigningCompany] = useState(false)
const [editUserId, setEditUserId] = useState<string | null>(null)
const editUser = useMemo(() => users.find((user) => user.id === editUserId) ?? null, [users, editUserId])
const [editForm, setEditForm] = useState({
name: "",
email: "",
role: "agent" as RoleOption,
tenantId: defaultTenantId,
companyId: "",
})
const [isSavingUser, setIsSavingUser] = useState(false)
const [isResettingPassword, setIsResettingPassword] = useState(false)
const [passwordPreview, setPasswordPreview] = useState<string | null>(null)
const normalizedRoles = useMemo(() => roleOptions ?? ROLE_OPTIONS, [roleOptions])
const teamUsers = useMemo(() => users.filter((user) => user.role !== "machine"), [users])
const machineUsers = useMemo(() => users.filter((user) => user.role === "machine"), [users])
// load companies for association
useEffect(() => {
void (async () => {
try {
const r = await fetch("/api/admin/companies", { credentials: "include" })
const j = (await r.json()) as { companies?: Array<{ id: string; name: string }> }
const items = (j.companies ?? []).map((c) => ({ id: c.id, name: c.name }))
setCompanies(items)
} catch {
// noop
const response = await fetch("/api/admin/companies", { credentials: "include" })
const json = (await response.json()) as { companies?: CompanyOption[] }
const mapped = (json.companies ?? []).map((company) => ({ id: company.id, name: company.name }))
setCompanies(mapped)
if (mapped.length > 0 && !linkCompanyId) {
setLinkCompanyId(mapped[0].id)
}
} catch (error) {
console.error("Falha ao carregar empresas", error)
}
})()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (!editUser) {
setEditForm({ name: "", email: "", role: "agent", tenantId: defaultTenantId, companyId: "" })
setPasswordPreview(null)
return
}
setEditForm({
name: editUser.name || "",
email: editUser.email,
role: editUser.role,
tenantId: editUser.tenantId || defaultTenantId,
companyId: editUser.companyId ?? "",
})
setPasswordPreview(null)
}, [editUser, defaultTenantId])
async function handleInviteSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!email || !email.includes("@")) {
const normalizedEmail = email.trim().toLowerCase()
if (!normalizedEmail || !normalizedEmail.includes("@")) {
toast.error("Informe um e-mail válido")
return
}
const payload = {
email,
name,
email: normalizedEmail,
name: name.trim(),
role,
tenantId,
expiresInDays: Number.parseInt(expiresInDays, 10),
@ -188,9 +242,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
async function handleRevoke(inviteId: string) {
const invite = invites.find((item) => item.id === inviteId)
if (!invite || invite.status !== "pending") {
return
}
if (!invite || invite.status !== "pending") return
const confirmed = window.confirm("Deseja revogar este convite?")
if (!confirmed) return
@ -220,277 +272,555 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
}
}
async function handleAssignCompany(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const normalizedEmail = linkEmail.trim().toLowerCase()
if (!normalizedEmail || !normalizedEmail.includes("@")) {
toast.error("Informe um e-mail válido para vincular")
return
}
if (!linkCompanyId) {
toast.error("Selecione a empresa para vincular")
return
}
setAssigningCompany(true)
toast.loading("Vinculando colaborador...", { id: "assign-company" })
try {
const response = await fetch("/api/admin/users/assign-company", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: normalizedEmail, companyId: linkCompanyId }),
credentials: "include",
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Falha ao vincular")
}
toast.success("Colaborador vinculado com sucesso", { id: "assign-company" })
setLinkEmail("")
setLinkCompanyId("")
} catch (error) {
const message = error instanceof Error ? error.message : "Não foi possível vincular"
toast.error(message, { id: "assign-company" })
} finally {
setAssigningCompany(false)
}
}
async function handleSaveUser(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!editUser) return
const payload = {
name: editForm.name.trim(),
email: editForm.email.trim().toLowerCase(),
role: editForm.role,
tenantId: editForm.tenantId.trim() || defaultTenantId,
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
}
setIsSavingUser(true)
try {
const response = await fetch(`/api/admin/users/${editUser.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Não foi possível atualizar o usuário")
}
const data = (await response.json()) as { user: AdminUser }
setUsers((previous) => previous.map((item) => (item.id === data.user.id ? data.user : item)))
toast.success("Usuário atualizado com sucesso")
setEditUserId(null)
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao salvar alterações"
toast.error(message)
} finally {
setIsSavingUser(false)
}
}
async function handleResetPassword() {
if (!editUser) return
setIsResettingPassword(true)
toast.loading("Gerando nova senha...", { id: "reset-password" })
try {
const response = await fetch(`/api/admin/users/${editUser.id}/reset-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Falha ao gerar nova senha")
}
const data = (await response.json()) as { temporaryPassword: string }
setPasswordPreview(data.temporaryPassword)
toast.success("Senha temporária criada", { id: "reset-password" })
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao gerar senha"
toast.error(message, { id: "reset-password" })
} finally {
setIsResettingPassword(false)
}
}
const isMachineEditing = editUser?.role === "machine"
const companyOptions = useMemo(() => [
{ id: "", name: "Sem empresa vinculada" },
...companies,
], [companies])
return (
<Tabs defaultValue="invites" className="w-full">
<TabsList className="h-12 w-full justify-start rounded-xl bg-slate-100 p-1">
<TabsTrigger value="invites" className="rounded-lg">Convites</TabsTrigger>
<TabsTrigger value="users" className="rounded-lg">Usuários</TabsTrigger>
</TabsList>
<>
<Tabs defaultValue="users" className="w-full">
<TabsList className="h-12 w-full justify-start rounded-xl bg-slate-100 p-1">
<TabsTrigger value="users" className="rounded-lg">Equipe</TabsTrigger>
<TabsTrigger value="machines" className="rounded-lg">Agentes de máquina</TabsTrigger>
<TabsTrigger value="invites" className="rounded-lg">Convites</TabsTrigger>
</TabsList>
<TabsContent value="invites" className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle>Gerar convite</CardTitle>
<CardDescription>
Envie convites personalizados com validade controlada e acompanhe o status em tempo real.
</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={handleInviteSubmit}
className="grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_160px_160px_160px_auto]"
>
<div className="grid gap-2">
<Label htmlFor="invite-email">E-mail corporativo</Label>
<Input
id="invite-email"
type="email"
inputMode="email"
placeholder="nome@suaempresa.com"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
autoComplete="off"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="invite-name">Nome</Label>
<Input
id="invite-name"
placeholder="Nome completo"
value={name}
onChange={(event) => setName(event.target.value)}
autoComplete="off"
/>
</div>
<div className="grid gap-2">
<Label>Papel</Label>
<Select value={role} onValueChange={(value) => setRole(value as RoleOption)}>
<SelectTrigger id="invite-role">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
{normalizedRoles.map((item) => (
<SelectItem key={item} value={item}>
{formatRole(item)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="invite-tenant">Espaço (ID interno)</Label>
<Input
id="invite-tenant"
value={tenantId}
onChange={(event) => setTenantId(event.target.value)}
placeholder="ex.: principal"
/>
<p className="text-xs text-neutral-500">
Use este campo apenas se trabalhar com múltiplos espaços de clientes. Caso contrário, mantenha o valor padrão.
</p>
</div>
<div className="grid gap-2">
<Label>Expira em</Label>
<Select value={expiresInDays} onValueChange={setExpiresInDays}>
<SelectTrigger id="invite-expiration">
<SelectValue placeholder="7 dias" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">7 dias</SelectItem>
<SelectItem value="14">14 dias</SelectItem>
<SelectItem value="30">30 dias</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<Button type="submit" disabled={isPending} className="w-full">
{isPending ? "Gerando..." : "Gerar convite"}
</Button>
</div>
</form>
{lastInviteLink ? (
<div className="mt-4 flex flex-col gap-2 rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="font-medium text-neutral-900">Link de convite pronto</p>
<p className="text-neutral-600">Compartilhe com o convidado. O link expira automaticamente no prazo definido.</p>
<p className="mt-2 truncate font-mono text-xs text-neutral-500">{lastInviteLink}</p>
</div>
<Button type="button" variant="outline" onClick={() => handleCopy(lastInviteLink)}>
Copiar link
</Button>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Vincular usuário a empresa</CardTitle>
<CardDescription>Associe um colaborador à sua empresa (usado para escopo de gestores e relatórios).</CardDescription>
</CardHeader>
<CardContent>
<form
className="grid grid-cols-1 gap-4 md:grid-cols-[1fr_1fr_auto]"
onSubmit={(e) => {
e.preventDefault()
if (!linkEmail || !linkCompanyId) {
toast.error("Informe e-mail e empresa")
return
}
startTransition(async () => {
toast.loading("Vinculando...", { id: "assign-company" })
try {
const r = await fetch("/api/admin/users/assign-company", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: linkEmail, companyId: linkCompanyId }),
credentials: "include",
})
if (!r.ok) throw new Error("failed")
toast.success("Usuário vinculado à empresa!", { id: "assign-company" })
} catch {
toast.error("Não foi possível vincular", { id: "assign-company" })
}
})
}}
>
<div className="grid gap-2">
<Label>E-mail do usuário</Label>
<Input value={linkEmail} onChange={(e) => setLinkEmail(e.target.value)} placeholder="colaborador@empresa.com" />
</div>
<div className="grid gap-2">
<Label>Empresa</Label>
<Select value={linkCompanyId} onValueChange={setLinkCompanyId}>
<SelectTrigger>
<SelectValue placeholder="Selecionar" />
</SelectTrigger>
<SelectContent>
{companies.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<Button type="submit" disabled={isPending}>Vincular</Button>
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Convites emitidos</CardTitle>
<CardDescription>Histórico e status atual de todos os convites enviados para o workspace.</CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
<th className="py-3 pr-4 font-medium">Colaborador</th>
<th className="py-3 pr-4 font-medium">Papel</th>
<th className="py-3 pr-4 font-medium">Espaço</th>
<th className="py-3 pr-4 font-medium">Expira em</th>
<th className="py-3 pr-4 font-medium">Status</th>
<th className="py-3 font-medium">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{invites.map((invite) => (
<tr key={invite.id} className="hover:bg-slate-50">
<td className="py-3 pr-4">
<div className="flex flex-col">
<span className="font-medium text-neutral-800">{invite.name || invite.email}</span>
<span className="text-xs text-neutral-500">{invite.email}</span>
</div>
</td>
<td className="py-3 pr-4 text-neutral-600">{formatRole(invite.role)}</td>
<td className="py-3 pr-4 text-neutral-600">{formatTenantLabel(invite.tenantId, defaultTenantId)}</td>
<td className="py-3 pr-4 text-neutral-600">{formatDate(invite.expiresAt)}</td>
<td className="py-3 pr-4">
<Badge
variant={invite.status === "pending" ? "secondary" : invite.status === "accepted" ? "default" : invite.status === "expired" ? "outline" : "destructive"}
className="rounded-full px-3 py-1 text-[11px] uppercase tracking-wide"
>
{formatStatus(invite.status)}
</Badge>
</td>
<td className="py-3">
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => handleCopy(invite.inviteUrl)}>
Copiar link
</Button>
{invite.status === "pending" ? (
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-50"
onClick={() => handleRevoke(invite.id)}
disabled={revokingId === invite.id}
>
{revokingId === invite.id ? "Revogando..." : "Revogar"}
<TabsContent value="users" className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle>Equipe cadastrada</CardTitle>
<CardDescription>Usuários com acesso ao sistema. Edite papéis, dados pessoais ou gere uma nova senha.</CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
<th className="py-3 pr-4 font-medium">Nome</th>
<th className="py-3 pr-4 font-medium">E-mail</th>
<th className="py-3 pr-4 font-medium">Papel</th>
<th className="py-3 pr-4 font-medium">Empresa</th>
<th className="py-3 pr-4 font-medium">Espaço</th>
<th className="py-3 pr-4 font-medium">Criado em</th>
<th className="py-3 font-medium">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{teamUsers.map((user) => (
<tr key={user.id} className="hover:bg-slate-50">
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "—"}</td>
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
<td className="py-3 pr-4 text-neutral-600">{formatRole(user.role)}</td>
<td className="py-3 pr-4 text-neutral-600">{user.companyName ?? "—"}</td>
<td className="py-3 pr-4 text-neutral-600">{formatTenantLabel(user.tenantId, defaultTenantId)}</td>
<td className="py-3 pr-4 text-neutral-500">{formatDate(user.createdAt)}</td>
<td className="py-3">
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => setEditUserId(user.id)}>
Editar
</Button>
) : null}
</div>
</td>
</tr>
))}
{teamUsers.length === 0 ? (
<tr>
<td colSpan={7} className="py-6 text-center text-neutral-500">
Nenhum usuário cadastrado até o momento.
</td>
</tr>
) : null}
</tbody>
</table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Vincular usuário a empresa</CardTitle>
<CardDescription>Associe colaboradores existentes a uma empresa para liberar painéis de gestores e filtros específicos.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleAssignCompany} className="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,2fr)_auto]">
<div className="grid gap-2">
<Label>E-mail do usuário</Label>
<Input
value={linkEmail}
onChange={(event) => setLinkEmail(event.target.value)}
placeholder="colaborador@empresa.com"
type="email"
required
/>
</div>
<div className="grid gap-2">
<Label>Empresa</Label>
<Select value={linkCompanyId} onValueChange={setLinkCompanyId}>
<SelectTrigger>
<SelectValue placeholder="Selecionar" />
</SelectTrigger>
<SelectContent>
{companies.map((company) => (
<SelectItem key={company.id} value={company.id}>
{company.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<Button type="submit" disabled={assigningCompany || companies.length === 0}>
{assigningCompany ? "Vinculando..." : "Vincular"}
</Button>
</div>
</form>
<p className="mt-2 text-xs text-neutral-500">Caso a empresa ainda não exista, cadastre-a em <Link href="/admin/companies" className="underline underline-offset-4">Admin Empresas &amp; clientes</Link>.</p>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="machines" className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle>Agentes de máquina</CardTitle>
<CardDescription>Contas provisionadas automaticamente via agente desktop. Ajustes de vínculo podem ser feitos em <Link href="/admin/machines" className="underline underline-offset-4">Admin Máquinas</Link>.</CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
<th className="py-3 pr-4 font-medium">Identificação</th>
<th className="py-3 pr-4 font-medium">E-mail técnico</th>
<th className="py-3 pr-4 font-medium">Perfil</th>
<th className="py-3 pr-4 font-medium">Criado em</th>
<th className="py-3 font-medium">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{machineUsers.map((user) => (
<tr key={user.id} className="hover:bg-slate-50">
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "Máquina"}</td>
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
<td className="py-3 pr-4 text-neutral-600">{user.machinePersona ? user.machinePersona === "manager" ? "Gestor" : "Colaborador" : "—"}</td>
<td className="py-3 pr-4 text-neutral-500">{formatDate(user.createdAt)}</td>
<td className="py-3">
<Button variant="outline" size="sm" asChild>
<Link href="/admin/machines">Gerenciar</Link>
</Button>
</td>
</tr>
))}
{machineUsers.length === 0 ? (
<tr>
<td colSpan={5} className="py-6 text-center text-neutral-500">
Nenhuma máquina provisionada ainda.
</td>
</tr>
) : null}
</tbody>
</table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="invites" className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle>Gerar convite</CardTitle>
<CardDescription>Envie convites personalizados com validade controlada e acompanhe o status em tempo real.</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={handleInviteSubmit}
className="grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_160px_160px_160px_auto]"
>
<div className="grid gap-2">
<Label htmlFor="invite-email">E-mail corporativo</Label>
<Input
id="invite-email"
type="email"
inputMode="email"
placeholder="nome@suaempresa.com"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
autoComplete="off"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="invite-name">Nome</Label>
<Input
id="invite-name"
placeholder="Nome completo"
value={name}
onChange={(event) => setName(event.target.value)}
autoComplete="off"
/>
</div>
<div className="grid gap-2">
<Label>Papel</Label>
<Select value={role} onValueChange={(value) => setRole(value as RoleOption)}>
<SelectTrigger id="invite-role">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
{normalizedRoles
.filter((option) => option !== "machine")
.map((option) => (
<SelectItem key={option} value={option}>
{formatRole(option)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="invite-tenant">Espaço (ID interno)</Label>
<Input
id="invite-tenant"
value={tenantId}
onChange={(event) => setTenantId(event.target.value)}
placeholder="ex.: principal"
/>
<p className="text-xs text-neutral-500">
Use este campo apenas se trabalhar com múltiplos espaços de clientes. Caso contrário, mantenha o valor padrão.
</p>
</div>
<div className="grid gap-2">
<Label>Expira em</Label>
<Select value={expiresInDays} onValueChange={setExpiresInDays}>
<SelectTrigger id="invite-expiration">
<SelectValue placeholder="7 dias" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">7 dias</SelectItem>
<SelectItem value="14">14 dias</SelectItem>
<SelectItem value="30">30 dias</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<Button type="submit" disabled={isPending} className="w-full">
{isPending ? "Gerando..." : "Gerar convite"}
</Button>
</div>
</form>
{lastInviteLink ? (
<div className="mt-4 flex flex-col gap-2 rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="font-medium text-neutral-900">Link de convite pronto</p>
<p className="text-neutral-600">Compartilhe com o convidado. O link expira automaticamente no prazo selecionado.</p>
<code className="mt-2 block rounded bg-white px-3 py-1 text-xs text-neutral-700">{lastInviteLink}</code>
</div>
<Button variant="outline" onClick={() => handleCopy(lastInviteLink)}>Copiar link</Button>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Convites emitidos</CardTitle>
<CardDescription>Histórico e status atual de todos os convites enviados para o workspace.</CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
<th className="py-3 pr-4 font-medium">Colaborador</th>
<th className="py-3 pr-4 font-medium">Papel</th>
<th className="py-3 pr-4 font-medium">Espaço</th>
<th className="py-3 pr-4 font-medium">Expira em</th>
<th className="py-3 pr-4 font-medium">Status</th>
<th className="py-3 font-medium">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{invites.map((invite) => (
<tr key={invite.id} className="hover:bg-slate-50">
<td className="py-3 pr-4">
<div className="flex flex-col">
<span className="font-medium text-neutral-800">{invite.name || invite.email}</span>
<span className="text-xs text-neutral-500">{invite.email}</span>
</div>
</td>
<td className="py-3 pr-4 text-neutral-600">{formatRole(invite.role)}</td>
<td className="py-3 pr-4 text-neutral-600">{formatTenantLabel(invite.tenantId, defaultTenantId)}</td>
<td className="py-3 pr-4 text-neutral-600">{formatDate(invite.expiresAt)}</td>
<td className="py-3 pr-4">
<Badge
variant={invite.status === "pending" ? "secondary" : invite.status === "accepted" ? "default" : invite.status === "expired" ? "outline" : "destructive"}
className="rounded-full px-3 py-1 text-[11px] uppercase tracking-wide"
>
{formatStatus(invite.status)}
</Badge>
</td>
<td className="py-3">
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => handleCopy(invite.inviteUrl)}>
Copiar link
</Button>
{invite.status === "pending" ? (
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-50"
onClick={() => handleRevoke(invite.id)}
disabled={revokingId === invite.id}
>
{revokingId === invite.id ? "Revogando..." : "Revogar"}
</Button>
) : null}
</div>
</td>
</tr>
))}
{invites.length === 0 ? (
<tr>
<td colSpan={6} className="py-6 text-center text-neutral-500">
Nenhum convite emitido até o momento.
</td>
</tr>
) : null}
</tbody>
</table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<Sheet open={Boolean(editUser)} onOpenChange={(open) => (!open ? setEditUserId(null) : null)}>
<SheetContent position="right" size="lg" className="space-y-6 overflow-y-auto">
<SheetHeader>
<SheetTitle>Editar usuário</SheetTitle>
<SheetDescription>Atualize os dados cadastrais, papel e vínculo do colaborador.</SheetDescription>
</SheetHeader>
{editUser ? (
<form onSubmit={handleSaveUser} className="space-y-6">
<div className="grid gap-4">
<div className="grid gap-2">
<Label>Nome</Label>
<Input
value={editForm.name}
onChange={(event) => setEditForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder="Nome completo"
disabled={isSavingUser || isMachineEditing}
required
/>
</div>
<div className="grid gap-2">
<Label>E-mail</Label>
<Input
value={editForm.email}
onChange={(event) => setEditForm((prev) => ({ ...prev, email: event.target.value }))}
type="email"
disabled={isSavingUser || isMachineEditing}
required
/>
</div>
<div className="grid gap-2">
<Label>Papel</Label>
<Select
value={editForm.role}
onValueChange={(value) => setEditForm((prev) => ({ ...prev, role: value as RoleOption }))}
disabled={isSavingUser || isMachineEditing}
>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
{normalizedRoles
.filter((option) => option !== "machine")
.map((option) => (
<SelectItem key={option} value={option}>
{formatRole(option)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Espaço (tenant)</Label>
<Input
value={editForm.tenantId}
onChange={(event) => setEditForm((prev) => ({ ...prev, tenantId: event.target.value }))}
placeholder="tenant-atlas"
disabled={isSavingUser || isMachineEditing}
/>
</div>
<div className="grid gap-2">
<Label>Empresa vinculada</Label>
<Select
value={editForm.companyId}
onValueChange={(value) => setEditForm((prev) => ({ ...prev, companyId: value }))}
disabled={isSavingUser}
>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
{companyOptions.map((company) => (
<SelectItem key={company.id} value={company.id}>
{company.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-neutral-500">Essa seleção substitui o vínculo atual no portal do cliente.</p>
</div>
{isMachineEditing ? (
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-3 text-sm text-neutral-600">
Os ajustes detalhados de agentes de máquina são feitos em <Link href="/admin/machines" className="underline underline-offset-4">Admin Máquinas</Link>.
</div>
) : (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="font-medium text-neutral-900">Gerar nova senha</p>
<p className="text-xs text-neutral-500">Uma senha temporária será exibida abaixo. Compartilhe com o usuário e peça para trocá-la no primeiro acesso.</p>
</div>
</td>
</tr>
))}
{invites.length === 0 ? (
<tr>
<td colSpan={6} className="py-6 text-center text-neutral-500">
Nenhum convite emitido até o momento.
</td>
</tr>
) : null}
</tbody>
</table>
</CardContent>
</Card>
</TabsContent>
<Button type="button" variant="outline" onClick={handleResetPassword} disabled={isResettingPassword}>
{isResettingPassword ? "Gerando..." : "Gerar senha"}
</Button>
</div>
{passwordPreview ? (
<div className="mt-3 flex flex-wrap items-center gap-3 rounded-md border border-slate-300 bg-white px-3 py-2">
<code className="text-sm font-semibold text-neutral-900">{passwordPreview}</code>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => navigator.clipboard.writeText(passwordPreview).then(() => toast.success("Senha copiada"))}
>
Copiar
</Button>
</div>
) : null}
</div>
)}
</div>
<TabsContent value="users" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Equipe cadastrada</CardTitle>
<CardDescription>Usuários ativos e provisionados via convites aceitos.</CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
<th className="py-3 pr-4 font-medium">Nome</th>
<th className="py-3 pr-4 font-medium">E-mail</th>
<th className="py-3 pr-4 font-medium">Papel</th>
<th className="py-3 pr-4 font-medium">Espaço</th>
<th className="py-3 font-medium">Criado em</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{users.map((user) => (
<tr key={user.id} className="hover:bg-slate-50">
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "—"}</td>
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
<td className="py-3 pr-4 text-neutral-600">{formatRole(user.role)}</td>
<td className="py-3 pr-4 text-neutral-600">{formatTenantLabel(user.tenantId, defaultTenantId)}</td>
<td className="py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
</tr>
))}
{users.length === 0 ? (
<tr>
<td colSpan={5} className="py-6 text-center text-neutral-500">
Nenhum usuário cadastrado até o momento.
</td>
</tr>
) : null}
</tbody>
</table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<SheetFooter className="flex flex-col gap-2 sm:flex-row sm:gap-3">
<Button type="button" variant="outline" onClick={() => setEditUserId(null)} disabled={isSavingUser}>
Cancelar
</Button>
<Button type="submit" disabled={isSavingUser || isMachineEditing} className="sm:ml-auto">
{isSavingUser ? "Salvando..." : "Salvar alterações"}
</Button>
</SheetFooter>
</form>
) : null}
</SheetContent>
</Sheet>
</>
)
}

View file

@ -19,6 +19,7 @@ import {
UserPlus,
BellRing,
ChevronDown,
ShieldCheck,
} from "lucide-react"
import { usePathname } from "next/navigation"
@ -73,7 +74,7 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
url: "/tickets",
icon: Ticket,
requiredRole: "staff",
children: [{ title: "Resolvidos", url: "/tickets/resolved", requiredRole: "staff" }],
children: [{ title: "Resolvidos", url: "/tickets/resolved", icon: ShieldCheck, requiredRole: "staff" }],
},
{ title: "Visualizações", url: "/views", icon: PanelsTopLeft, requiredRole: "staff" },
{ title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" },
@ -263,6 +264,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarMenuItem key={`${item.title}-${child.title}`}>
<SidebarMenuButton asChild isActive={isActive(child)}>
<a href={child.url} className="gap-2 pl-7 text-sm">
{child.icon ? <child.icon className="size-3.5 text-neutral-500" /> : null}
<span>{child.title}</span>
</a>
</SidebarMenuButton>

View file

@ -1,6 +1,6 @@
"use client"
import { useCallback, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import {
IconDotsVertical,
@ -45,6 +45,12 @@ export function NavUser({ user }: NavUserProps) {
const { isMobile } = useSidebar()
const router = useRouter()
const [isSigningOut, setIsSigningOut] = useState(false)
const [isDesktopShell, setIsDesktopShell] = useState(false)
useEffect(() => {
if (typeof window === "undefined") return
setIsDesktopShell(Boolean((window as typeof window & { __TAURI__?: unknown }).__TAURI__))
}, [])
const initials = useMemo(() => {
const source = normalizedUser.name?.trim() || normalizedUser.email?.trim() || ""
@ -135,17 +141,21 @@ export function NavUser({ user }: NavUserProps) {
<span>Notificações (em breve)</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
handleSignOut()
}}
disabled={isSigningOut}
>
<IconLogout className="size-4" />
<span>{isSigningOut ? "Encerrando…" : "Encerrar sessão"}</span>
</DropdownMenuItem>
{!isDesktopShell ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
handleSignOut()
}}
disabled={isSigningOut}
>
<IconLogout className="size-4" />
<span>{isSigningOut ? "Encerrando…" : "Encerrar sessão"}</span>
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>

View file

@ -28,8 +28,12 @@ export function PortalShell({ children }: PortalShellProps) {
const isMachineSession = session?.user.role === "machine"
const personaValue = machineContext?.persona ?? session?.user.machinePersona ?? null
const displayName = machineContext?.assignedUserName ?? session?.user.name ?? session?.user.email ?? "Cliente"
const displayEmail = machineContext?.assignedUserEmail ?? session?.user.email ?? ""
const collaboratorName = machineContext?.assignedUserName?.trim() ?? ""
const collaboratorEmail = machineContext?.assignedUserEmail?.trim() ?? ""
const userName = session?.user.name?.trim() ?? ""
const userEmail = session?.user.email?.trim() ?? ""
const displayName = collaboratorName || userName || collaboratorEmail || userEmail || "Cliente"
const displayEmail = collaboratorEmail || userEmail
const personaLabel = personaValue === "manager" ? "Gestor" : "Colaborador"
const initials = useMemo(() => {
@ -57,31 +61,40 @@ export function PortalShell({ children }: PortalShellProps) {
}
}
const isNavItemActive = (itemHref: string) => {
if (itemHref === "/portal/tickets") {
if (pathname === "/portal" || pathname === "/portal/tickets") return true
if (/^\/portal\/tickets\/[A-Za-z0-9_-]+$/.test(pathname) && !pathname.endsWith("/new")) return true
return false
}
return pathname === itemHref
}
return (
<div className="flex min-h-screen flex-col bg-gradient-to-b from-slate-50 via-slate-50 to-white">
<header className="border-b border-slate-200 bg-white/90 backdrop-blur">
<div className="mx-auto flex w-full max-w-6xl items-center justify-between gap-4 px-6 py-4">
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-4 px-6 py-4">
<div className="flex items-center gap-3">
<span className="flex size-9 items-center justify-center rounded-xl bg-neutral-900 text-white shadow-sm">
<GalleryVerticalEnd className="size-4" />
</span>
<div className="flex flex-col">
<span className="text-xs font-semibold uppercase tracking-[0.12em] text-neutral-500">
<span className="text-xs font-semibold uppercase tracking-[0.08em] text-neutral-500 sm:tracking-[0.12em]">
Portal do cliente
</span>
<span className="text-lg font-semibold text-neutral-900">Raven</span>
</div>
</div>
<nav className="flex items-center gap-3 text-sm font-medium">
<nav className="flex w-full flex-wrap items-center gap-2 text-sm font-medium sm:w-auto sm:justify-center">
{navItems.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`)
const isActive = isNavItemActive(item.href)
const Icon = item.icon
return (
<Link
key={item.href}
href={item.href}
className={cn(
"inline-flex items-center gap-2 rounded-full px-4 py-2 transition",
"inline-flex w-full items-center justify-center gap-2 rounded-full px-4 py-2 transition sm:w-auto",
isActive
? "bg-neutral-900 text-white shadow-sm"
: "bg-transparent text-neutral-700 hover:bg-neutral-100"
@ -93,7 +106,7 @@ export function PortalShell({ children }: PortalShellProps) {
)
})}
</nav>
<div className="flex items-center gap-3">
<div className="flex w-full flex-col items-start gap-3 sm:w-auto sm:flex-row sm:items-center">
<div className="flex items-center gap-2 text-sm">
<Avatar className="size-9 border border-slate-200">
<AvatarImage src={session?.user.avatarUrl ?? undefined} alt={displayName ?? ""} />
@ -101,7 +114,7 @@ export function PortalShell({ children }: PortalShellProps) {
</Avatar>
<div className="flex flex-col leading-tight">
<span className="font-semibold text-neutral-900">{displayName}</span>
<span className="text-xs text-neutral-500">{displayEmail}</span>
<span className="text-xs text-neutral-500">{displayEmail || "Sem e-mail definido"}</span>
{personaValue ? (
<span className="text-[10px] uppercase tracking-wide text-neutral-400">{personaLabel}</span>
) : null}
@ -113,7 +126,7 @@ export function PortalShell({ children }: PortalShellProps) {
variant="outline"
onClick={handleSignOut}
disabled={isSigningOut}
className="inline-flex items-center gap-2"
className="inline-flex items-center gap-2 self-stretch sm:self-auto"
>
<LogOut className="size-4" />
Sair
@ -127,9 +140,14 @@ export function PortalShell({ children }: PortalShellProps) {
{children}
</main>
<footer className="border-t border-slate-200 bg-white/70">
<div className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 py-4 text-xs text-neutral-500">
<span>&copy; {new Date().getFullYear()} Raven</span>
<span>Suporte: suporte@sistema.dev</span>
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-3 px-6 py-4 text-xs text-neutral-500">
<span>&copy; {new Date().getFullYear()} Raven Desenvolvido pela Rever Tecnologia</span>
<span>
Suporte:{" "}
<a href="mailto:suporte@rever.com.br" className="font-medium text-neutral-600 hover:text-neutral-800">
suporte@rever.com.br
</a>
</span>
</div>
</footer>
</div>

View file

@ -84,13 +84,16 @@ export function RecentTicketsPanel() {
const { convexUserId } = useAuth()
const ticketsRaw = useQuery(
api.tickets.list,
convexUserId ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users">, limit: 6 } : "skip"
convexUserId ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users">, limit: 12 } : "skip"
)
const [enteringId, setEnteringId] = useState<string | null>(null)
const previousIdsRef = useRef<string[]>([])
const tickets = useMemo(
() => mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]).slice(0, 6),
() =>
mapTicketsFromServerList((ticketsRaw ?? []) as unknown[])
.filter((ticket) => ticket.status !== "RESOLVED")
.slice(0, 6),
[ticketsRaw]
)

View file

@ -133,37 +133,38 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
return (
<Card className="gap-0 rounded-3xl border border-slate-200 bg-white py-0 shadow-sm">
<CardContent className="p-0">
<Table className="min-w-full overflow-hidden rounded-3xl table-fixed">
<TableHeader className="bg-slate-100/80">
<TableRow className="bg-transparent text-[11px] uppercase tracking-wide text-neutral-600">
<TableHead className="w-[120px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6">
<div className="w-full overflow-x-auto">
<Table className="w-full min-w-[920px] overflow-hidden rounded-3xl">
<TableHeader className="bg-slate-100/80">
<TableRow className="bg-transparent text-[11px] uppercase tracking-wide text-neutral-600">
<TableHead className="w-[110px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 sm:w-[120px]">
Ticket
</TableHead>
<TableHead className="w-[40%] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6">
<TableHead className="w-[38%] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 sm:w-[40%]">
Assunto
</TableHead>
<TableHead className="hidden w-[120px] pl-8 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 lg:table-cell">
<TableHead className="hidden w-[120px] pl-6 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 lg:table-cell">
Fila
</TableHead>
<TableHead className="hidden w-[80px] pl-8 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 md:table-cell">
<TableHead className="hidden w-[70px] pl-6 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 md:table-cell sm:w-[80px]">
Canal
</TableHead>
<TableHead className="hidden w-[180px] pl-8 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 lg:table-cell">
<TableHead className="hidden w-[150px] pl-6 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 lg:table-cell xl:w-[180px]">
Empresa
</TableHead>
<TableHead className="hidden w-[100px] pl-1 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 md:table-cell">
<TableHead className="hidden w-[90px] pl-1 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 md:table-cell lg:w-[100px]">
Prioridade
</TableHead>
<TableHead className="w-[230px] pl-14 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6">
<TableHead className="w-[160px] pl-6 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 sm:w-[200px] lg:pl-10 xl:w-[230px] xl:pl-14">
Status
</TableHead>
<TableHead className="hidden w-[110px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 lg:table-cell">
<TableHead className="hidden w-[110px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 lg:table-cell">
Tempo
</TableHead>
<TableHead className="hidden w-[200px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 xl:table-cell">
<TableHead className="hidden w-[180px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 xl:table-cell xl:w-[200px]">
Responsável
</TableHead>
<TableHead className="w-[140px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6">
<TableHead className="w-[130px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 sm:w-[140px]">
Atualizado
</TableHead>
</TableRow>
@ -217,12 +218,12 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
</div>
</div>
</TableCell>
<TableCell className={`${cellClass} hidden lg:table-cell pl-8`}>
<TableCell className={`${cellClass} hidden lg:table-cell pl-6`}>
<span className="text-sm font-semibold text-neutral-800">
{ticket.queue ?? "Sem fila"}
</span>
</TableCell>
<TableCell className={`${cellClass} hidden md:table-cell pl-8`}>
<TableCell className={`${cellClass} hidden md:table-cell pl-6`}>
<div className="flex items-center">
<span className="sr-only">Canal {channelLabel[ticket.channel]}</span>
<span
@ -234,12 +235,12 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
</span>
</div>
</TableCell>
<TableCell className={`${cellClass} hidden lg:table-cell pl-8`}>
<TableCell className={`${cellClass} hidden lg:table-cell pl-6`}>
<span className="max-w-[160px] truncate text-sm text-neutral-800" title={((ticket.company ?? null) as { name?: string } | null)?.name ?? "—"}>
{((ticket.company ?? null) as { name?: string } | null)?.name ?? "—"}
</span>
</TableCell>
<TableCell className={`${cellClass} hidden md:table-cell pl-1 pr-8`}>
<TableCell className={`${cellClass} hidden md:table-cell pl-1 pr-6 lg:pr-8`}>
<div
className="inline-flex"
onClick={(event) => event.stopPropagation()}
@ -248,9 +249,9 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
</div>
</TableCell>
<TableCell className={`${cellClass} pl-14`}>
<TableCell className={`${cellClass} pl-6 sm:pl-10 xl:pl-14`}>
<div className="flex flex-col gap-1">
<span className={cn("text-sm font-semibold break-words leading-tight max-w-[140px] sm:max-w-[180px]", statusTone[ticket.status])}>
<span className={cn("text-sm font-semibold break-words leading-tight max-w-[140px] sm:max-w-[180px] lg:max-w-[210px]", statusTone[ticket.status])}>
{statusLabel[ticket.status]}
</span>
{ticket.metrics?.timeWaitingMinutes ? (
@ -285,7 +286,8 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
)
})}
</TableBody>
</Table>
</Table>
</div>
{tickets.length === 0 && (
<Empty className="my-6">
<EmptyHeader>