feat: link tickets in comments and align admin sidebars

This commit is contained in:
Esdras Renan 2025-10-23 00:46:50 -03:00
parent c35eb673d3
commit b0f57009ac
15 changed files with 1606 additions and 424 deletions

View file

@ -20,6 +20,14 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useQuery } from "convex/react"
@ -356,6 +364,74 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
})
}, [combinedBaseUsers, usersSearch, usersCompanyFilter])
const [teamPageSize, setTeamPageSize] = useState<number>(10)
const [teamPageIndex, setTeamPageIndex] = useState<number>(0)
const teamTotal = filteredTeamUsers.length
const teamPageCount = Math.max(1, Math.ceil(teamTotal / teamPageSize))
const teamPaginated = useMemo(
() => filteredTeamUsers.slice(teamPageIndex * teamPageSize, teamPageIndex * teamPageSize + teamPageSize),
[filteredTeamUsers, teamPageIndex, teamPageSize]
)
const teamStart = teamTotal === 0 ? 0 : teamPageIndex * teamPageSize + 1
const teamEnd = teamTotal === 0 ? 0 : Math.min(teamTotal, teamPageIndex * teamPageSize + teamPageSize)
useEffect(() => {
setTeamPageIndex(0)
}, [teamSearch, teamRoleFilter, teamCompanyFilter])
useEffect(() => {
if (teamPageIndex > teamPageCount - 1) {
setTeamPageIndex(Math.max(0, teamPageCount - 1))
}
}, [teamPageIndex, teamPageCount])
const [usersPageSize, setUsersPageSize] = useState<number>(10)
const [usersPageIndex, setUsersPageIndex] = useState<number>(0)
const usersTotal = filteredCombinedUsers.length
const usersPageCount = Math.max(1, Math.ceil(usersTotal / usersPageSize))
const usersPaginated = useMemo(
() =>
filteredCombinedUsers.slice(
usersPageIndex * usersPageSize,
usersPageIndex * usersPageSize + usersPageSize
),
[filteredCombinedUsers, usersPageIndex, usersPageSize]
)
const usersStart = usersTotal === 0 ? 0 : usersPageIndex * usersPageSize + 1
const usersEnd = usersTotal === 0 ? 0 : Math.min(usersTotal, usersPageIndex * usersPageSize + usersPageSize)
useEffect(() => {
setUsersPageIndex(0)
}, [usersSearch, usersTypeFilter, usersCompanyFilter])
useEffect(() => {
if (usersPageIndex > usersPageCount - 1) {
setUsersPageIndex(Math.max(0, usersPageCount - 1))
}
}, [usersPageIndex, usersPageCount])
const [invitesPageSize, setInvitesPageSize] = useState<number>(10)
const [invitesPageIndex, setInvitesPageIndex] = useState<number>(0)
const invitesTotal = invites.length
const invitesPageCount = Math.max(1, Math.ceil(invitesTotal / invitesPageSize))
const paginatedInvites = useMemo(
() => invites.slice(invitesPageIndex * invitesPageSize, invitesPageIndex * invitesPageSize + invitesPageSize),
[invites, invitesPageIndex, invitesPageSize]
)
const invitesStart = invitesTotal === 0 ? 0 : invitesPageIndex * invitesPageSize + 1
const invitesEnd =
invitesTotal === 0 ? 0 : Math.min(invitesTotal, invitesPageIndex * invitesPageSize + invitesPageSize)
useEffect(() => {
setInvitesPageIndex(0)
}, [invitesTotal])
useEffect(() => {
if (invitesPageIndex > invitesPageCount - 1) {
setInvitesPageIndex(Math.max(0, invitesPageCount - 1))
}
}, [invitesPageIndex, invitesPageCount])
useEffect(() => {
void (async () => {
try {
@ -1008,104 +1084,168 @@ async function handleDeleteUser() {
<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="w-10 py-3 pr-2 font-medium">
<div className="flex items-center justify-center">
<Checkbox
checked={allTeamSelected || (someTeamSelected && "indeterminate")}
onCheckedChange={(value) => toggleTeamSelectAll(!!value)}
aria-label="Selecionar todos"
<CardContent className="space-y-4">
<div className="w-full overflow-x-auto">
<div className="overflow-hidden rounded-lg border">
<table className="min-w-full table-fixed text-sm">
<thead className="bg-slate-100/80">
<tr className="text-left text-xs uppercase tracking-wide text-neutral-600">
<th className="w-10 px-4 py-3 font-semibold first:pl-6">
<div className="flex items-center justify-center">
<Checkbox
checked={allTeamSelected || (someTeamSelected && "indeterminate")}
onCheckedChange={(value) => toggleTeamSelectAll(!!value)}
aria-label="Selecionar todos"
/>
</div>
</th>
<th className="px-4 py-3 font-semibold text-neutral-600 first:pl-6">Nome</th>
<th className="px-4 py-3 font-semibold text-neutral-600">E-mail</th>
<th className="px-4 py-3 font-semibold text-neutral-600">Papel</th>
<th className="px-4 py-3 font-semibold text-neutral-600">Empresa</th>
<th className="px-4 py-3 font-semibold text-neutral-600">Máquinas</th>
<th className="px-4 py-3 font-semibold text-neutral-600">Criado em</th>
<th className="px-4 py-3 font-semibold text-neutral-600 last:pr-6">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 bg-white">
{teamPaginated.length > 0 ? (
teamPaginated.map((user) => (
<tr key={user.id} className="transition-colors hover:bg-slate-50/80">
<td className="px-4 py-3 first:pl-6">
<div className="flex items-center justify-center">
<Checkbox
checked={teamSelection.has(user.id)}
onCheckedChange={(checked) => {
setTeamSelection((prev) => {
const next = new Set(prev)
if (checked) next.add(user.id)
else next.delete(user.id)
return next
})
}}
aria-label="Selecionar linha"
/>
</div>
</td>
<td className="px-4 py-3 font-medium text-neutral-800 first:pl-6">{user.name || "—"}</td>
<td className="px-4 py-3 text-neutral-600">{user.email}</td>
<td className="px-4 py-3 text-neutral-600">{formatRole(user.role)}</td>
<td className="px-4 py-3 text-neutral-600">{user.companyName ?? "—"}</td>
<td className="px-4 py-3 text-neutral-600">
{(() => {
const list = machinesByUserEmail.get((user.email ?? "").toLowerCase()) ?? []
if (list.length === 0) return "—"
const names = list.map((m) => m.hostname || m.id)
const head = names.slice(0, 2).join(", ")
const extra = names.length > 2 ? ` +${names.length - 2}` : ""
return (
<span className="text-xs font-medium" title={names.join(", ")}>
{head}
{extra}
</span>
)
})()}
</td>
<td className="px-4 py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
<td className="px-4 py-3 last:pr-6">
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
disabled={!canManageUser(user.role)}
onClick={() => {
if (!canManageUser(user.role)) return
setEditUserId(user.id)
}}
>
Editar
</Button>
<Button
variant="outline"
size="sm"
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
disabled={!canManageUser(user.role)}
onClick={() => {
if (!canManageUser(user.role)) return
setDeleteUserId(user.id)
}}
>
Remover
</Button>
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan={8} className="px-6 py-6 text-center text-sm text-neutral-500">
{teamUsers.length === 0
? "Nenhum usuário cadastrado até o momento."
: "Nenhum usuário corresponde aos filtros atuais."}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<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">
<span>Itens por página</span>
<Select
value={`${teamPageSize}`}
onValueChange={(value) => {
const next = Number(value)
setTeamPageSize(next)
setTeamPageIndex(0)
}}
>
<SelectTrigger className="h-8 w-20">
<SelectValue placeholder={`${teamPageSize}`} />
</SelectTrigger>
<SelectContent align="end">
{[10, 20, 30, 50].map((n) => (
<SelectItem key={`team-page-${n}`} value={`${n}`}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
disabled={teamPageIndex === 0}
onClick={() => setTeamPageIndex((previous) => Math.max(0, previous - 1))}
/>
</div>
</th>
<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">Máquinas</th>
{/* Espaço removido */}
<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">
{filteredTeamUsers.map((user) => (
<tr key={user.id} className="hover:bg-slate-50">
<td className="py-3 pr-2">
<div className="flex items-center justify-center">
<Checkbox
checked={teamSelection.has(user.id)}
onCheckedChange={(checked) => {
setTeamSelection((prev) => {
const next = new Set(prev)
if (checked) next.add(user.id)
else next.delete(user.id)
return next
})
}}
aria-label="Selecionar linha"
/>
</div>
</td>
<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">
{(() => {
const list = machinesByUserEmail.get((user.email ?? '').toLowerCase()) ?? []
if (list.length === 0) return '—'
const names = list.map((m) => m.hostname || m.id)
const head = names.slice(0, 2).join(', ')
const extra = names.length > 2 ? ` +${names.length - 2}` : ''
return <span className="text-xs font-medium" title={names.join(', ')}>{head}{extra}</span>
})()}
</td>
{/* Espaço removido */}
<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"
disabled={!canManageUser(user.role)}
onClick={() => {
if (!canManageUser(user.role)) return
setEditUserId(user.id)
}}
>
Editar
</Button>
<Button
variant="outline"
size="sm"
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
disabled={!canManageUser(user.role)}
onClick={() => {
if (!canManageUser(user.role)) return
setDeleteUserId(user.id)
}}
>
Remover
</Button>
</div>
</td>
</tr>
))}
{filteredTeamUsers.length === 0 ? (
<tr>
<td colSpan={8} className="py-6 text-center text-neutral-500">
{teamUsers.length === 0
? "Nenhum usuário cadastrado até o momento."
: "Nenhum usuário corresponde aos filtros atuais."}
</td>
</tr>
) : null}
</tbody>
</table>
</PaginationItem>
<PaginationItem>
<PaginationLink
href="#"
isActive
onClick={(event) => {
event.preventDefault()
}}
>
{teamPageIndex + 1}
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext
disabled={teamPageIndex >= teamPageCount - 1}
onClick={() =>
setTeamPageIndex((previous) => Math.min(teamPageCount - 1, previous + 1))
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
</CardContent>
</Card>
@ -1226,112 +1366,193 @@ async function handleDeleteUser() {
<CardTitle>Usuários</CardTitle>
<CardDescription>Pessoas e máquinas com acesso ao sistema.</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="w-10 py-3 pr-2 font-medium">
<div className="flex items-center justify-center">
<Checkbox
checked={usersSelection.size > 0 && usersSelection.size === filteredCombinedUsers.length || (usersSelection.size > 0 && usersSelection.size < filteredCombinedUsers.length && "indeterminate")}
onCheckedChange={(value) => toggleUsersCombinedSelectAll(!!value)}
aria-label="Selecionar todos"
<CardContent className="space-y-4">
<div className="w-full overflow-x-auto">
<div className="overflow-hidden rounded-lg border">
<table className="min-w-full table-fixed text-sm">
<thead className="bg-slate-100/80">
<tr className="text-left text-xs uppercase tracking-wide text-neutral-600">
<th className="w-10 px-4 py-3 font-semibold first:pl-6">
<div className="flex items-center justify-center">
<Checkbox
checked={
usersSelection.size > 0 &&
usersSelection.size === filteredCombinedUsers.length
? true
: usersSelection.size > 0
? "indeterminate"
: false
}
onCheckedChange={(value) => toggleUsersCombinedSelectAll(!!value)}
aria-label="Selecionar todos"
/>
</div>
</th>
<th className="px-4 py-3 font-semibold text-neutral-600 first:pl-6">Nome</th>
<th className="px-4 py-3 font-semibold text-neutral-600">E-mail</th>
<th className="px-4 py-3 font-semibold text-neutral-600">Tipo</th>
<th className="px-4 py-3 font-semibold text-neutral-600">Perfil</th>
<th className="px-4 py-3 font-semibold text-neutral-600">Empresa</th>
<th className="px-4 py-3 font-semibold text-neutral-600">Criado em</th>
<th className="px-4 py-3 font-semibold text-neutral-600 last:pr-6">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 bg-white">
{usersPaginated.length > 0 ? (
usersPaginated.map((user) => (
<tr key={user.id} className="transition-colors hover:bg-slate-50/80">
<td className="px-4 py-3 first:pl-6">
<div className="flex items-center justify-center">
<Checkbox
checked={usersSelection.has(user.id)}
onCheckedChange={(checked) => {
setUsersSelection((prev) => {
const next = new Set(prev)
if (checked) next.add(user.id)
else next.delete(user.id)
return next
})
}}
aria-label="Selecionar linha"
/>
</div>
</td>
<td className="px-4 py-3 font-medium text-neutral-800 first:pl-6">
{user.name || (user.role === "machine" ? "Máquina" : "—")}
</td>
<td className="px-4 py-3 text-neutral-600">{user.email}</td>
<td className="px-4 py-3 text-neutral-600">
{user.role === "machine" ? "Máquina" : "Pessoa"}
</td>
<td className="px-4 py-3 text-neutral-600">
{user.role === "machine" ? (
user.machinePersona ? (
<Badge
variant={machinePersonaBadgeVariant(user.machinePersona)}
className="rounded-full px-3 py-1 text-xs font-medium"
>
{formatMachinePersona(user.machinePersona)}
</Badge>
) : (
<span className="text-neutral-500">Sem persona</span>
)
) : (
formatRole(user.role)
)}
</td>
<td className="px-4 py-3 text-neutral-600">{user.companyName ?? "—"}</td>
<td className="px-4 py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
<td className="px-4 py-3 last:pr-6">
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
disabled={!canManageUser(user.role)}
onClick={() => {
if (!canManageUser(user.role)) return
setEditUserId(user.id)
}}
>
Editar
</Button>
{user.role === "machine" ? (
<Button variant="ghost" size="sm" asChild>
<Link
href={
extractMachineId(user.email)
? `/admin/machines/${extractMachineId(user.email)}`
: "/admin/machines"
}
>
Detalhes da máquina
</Link>
</Button>
) : null}
<Button
variant="outline"
size="sm"
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
disabled={!canManageUser(user.role)}
onClick={() => {
if (!canManageUser(user.role)) return
setDeleteUserId(user.id)
}}
>
Remover
</Button>
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan={8} className="px-6 py-6 text-center text-sm text-neutral-500">
{combinedBaseUsers.length === 0
? "Nenhum usuário cadastrado até o momento."
: "Nenhum usuário corresponde aos filtros atuais."}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<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">
<span>Itens por página</span>
<Select
value={`${usersPageSize}`}
onValueChange={(value) => {
const next = Number(value)
setUsersPageSize(next)
setUsersPageIndex(0)
}}
>
<SelectTrigger className="h-8 w-20">
<SelectValue placeholder={`${usersPageSize}`} />
</SelectTrigger>
<SelectContent align="end">
{[10, 20, 30, 50].map((n) => (
<SelectItem key={`users-page-${n}`} value={`${n}`}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
disabled={usersPageIndex === 0}
onClick={() => setUsersPageIndex((previous) => Math.max(0, previous - 1))}
/>
</div>
</th>
<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">Tipo</th>
<th className="py-3 pr-4 font-medium">Perfil</th>
<th className="py-3 pr-4 font-medium">Empresa</th>
{/* Espaço removido */}
<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">
{filteredCombinedUsers.map((user) => (
<tr key={user.id} className="hover:bg-slate-50">
<td className="py-3 pr-2">
<div className="flex items-center justify-center">
<Checkbox
checked={usersSelection.has(user.id)}
onCheckedChange={(checked) => {
setUsersSelection((prev) => {
const next = new Set(prev)
if (checked) next.add(user.id)
else next.delete(user.id)
return next
})
}}
aria-label="Selecionar linha"
/>
</div>
</td>
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || (user.role === "machine" ? "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.role === "machine" ? "Máquina" : "Pessoa"}</td>
<td className="py-3 pr-4 text-neutral-600">
{user.role === "machine" ? (
user.machinePersona ? (
<Badge variant={machinePersonaBadgeVariant(user.machinePersona)} className="rounded-full px-3 py-1 text-xs font-medium">
{formatMachinePersona(user.machinePersona)}
</Badge>
) : (
<span className="text-neutral-500">Sem persona</span>
)
) : (
formatRole(user.role)
)}
</td>
<td className="py-3 pr-4 text-neutral-600">{user.companyName ?? "—"}</td>
{/* Espaço removido */}
<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"
disabled={!canManageUser(user.role)}
onClick={() => {
if (!canManageUser(user.role)) return
setEditUserId(user.id)
}}
>
Editar
</Button>
{user.role === "machine" ? (
<Button variant="ghost" size="sm" asChild>
<Link href={(extractMachineId(user.email) ? `/admin/machines/${extractMachineId(user.email)}` : "/admin/machines")}>Detalhes da máquina</Link>
</Button>
) : null}
<Button
variant="outline"
size="sm"
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
disabled={!canManageUser(user.role)}
onClick={() => {
if (!canManageUser(user.role)) return
setDeleteUserId(user.id)
}}
>
Remover
</Button>
</div>
</td>
</tr>
))}
{filteredCombinedUsers.length === 0 ? (
<tr>
<td colSpan={9} className="py-6 text-center text-neutral-500">
{combinedBaseUsers.length === 0
? "Nenhum usuário cadastrado até o momento."
: "Nenhum usuário corresponde aos filtros atuais."}
</td>
</tr>
) : null}
</tbody>
</table>
</PaginationItem>
<PaginationItem>
<PaginationLink
href="#"
isActive
onClick={(event) => {
event.preventDefault()
}}
>
{usersPageIndex + 1}
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext
disabled={usersPageIndex >= usersPageCount - 1}
onClick={() =>
setUsersPageIndex((previous) => Math.min(usersPageCount - 1, previous + 1))
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
@ -1422,113 +1643,172 @@ async function handleDeleteUser() {
<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="w-10 py-3 pr-2 font-medium">
<div className="flex items-center justify-center">
<Checkbox
checked={allInvitesSelected || (someInvitesSelected && "indeterminate")}
onCheckedChange={(value) => toggleInvitesSelectAll(!!value)}
aria-label="Selecionar todos"
/>
</div>
</th>
<th className="py-3 pr-4 font-medium">Colaborador</th>
<th className="py-3 pr-4 font-medium">Papel</th>
{/* Espaço removido */}
<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-2">
<div className="flex items-center justify-center">
<Checkbox
checked={inviteSelection.has(invite.id)}
onCheckedChange={(checked) => {
setInviteSelection((prev) => {
const next = new Set(prev)
if (checked) next.add(invite.id)
else next.delete(invite.id)
return next
})
}}
aria-label="Selecionar linha"
<CardContent className="space-y-4">
<div className="w-full overflow-x-auto">
<div className="overflow-hidden rounded-lg border">
<table className="min-w-full table-fixed text-sm">
<thead className="bg-slate-100/80">
<tr className="text-left text-xs uppercase tracking-wide text-neutral-600">
<th className="w-10 px-4 py-3 font-semibold first:pl-6">
<div className="flex items-center justify-center">
<Checkbox
checked={allInvitesSelected || (someInvitesSelected && "indeterminate")}
onCheckedChange={(value) => toggleInvitesSelectAll(!!value)}
aria-label="Selecionar todos"
/>
</div>
</th>
<th className="px-4 py-3 font-semibold text-neutral-600 first:pl-6">Colaborador</th>
<th className="px-4 py-3 font-semibold text-neutral-600">Papel</th>
<th className="px-4 py-3 font-semibold text-neutral-600">Expira em</th>
<th className="px-4 py-3 font-semibold text-neutral-600">Status</th>
<th className="px-4 py-3 font-semibold text-neutral-600 last:pr-6">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 bg-white">
{paginatedInvites.length > 0 ? (
paginatedInvites.map((invite) => (
<tr key={invite.id} className="transition-colors hover:bg-slate-50/80">
<td className="px-4 py-3 first:pl-6">
<div className="flex items-center justify-center">
<Checkbox
checked={inviteSelection.has(invite.id)}
onCheckedChange={(checked) => {
setInviteSelection((prev) => {
const next = new Set(prev)
if (checked) next.add(invite.id)
else next.delete(invite.id)
return next
})
}}
aria-label="Selecionar linha"
/>
</div>
</td>
<td className="px-4 py-3 first:pl-6">
<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="px-4 py-3 text-neutral-600">{formatRole(invite.role)}</td>
<td className="px-4 py-3 text-neutral-600">{formatDate(invite.expiresAt)}</td>
<td className="px-4 py-3">
<Badge
variant={inviteStatusVariant(invite.status)}
className="rounded-full px-3 py-1 text-xs font-medium"
>
{formatStatus(invite.status)}
</Badge>
</td>
<td className="px-4 py-3 last:pr-6">
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => handleCopy(invite.inviteUrl)}>
Copiar link
</Button>
{invite.status === "pending" && canManageInvite(invite.role) ? (
<Button
variant="ghost"
size="sm"
className="text-red-600 transition-colors hover:bg-red-500/10"
onClick={() => setRevokeDialogInviteId(invite.id)}
disabled={revokingId === invite.id}
>
{revokingId === invite.id ? "Revogando..." : "Revogar"}
</Button>
) : null}
{invite.status === "revoked" && canReactivateInvitePolicy(invite) && canManageInvite(invite.role) ? (
<Button
variant="outline"
size="sm"
className="border-amber-400 text-amber-600 hover:bg-amber-50"
onClick={() => handleReactivate(invite)}
disabled={reactivatingId === invite.id}
>
{reactivatingId === invite.id ? "Reativando..." : "Reativar"}
</Button>
) : null}
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan={6} className="px-6 py-6 text-center text-sm text-neutral-500">
Nenhum convite emitido até o momento.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<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>{invitesTotal === 0 ? "Nenhum registro" : `Mostrando ${invitesStart}-${invitesEnd} de ${invitesTotal}`}</div>
<div className="flex flex-col-reverse gap-3 md:flex-row md:items-center md:gap-4">
<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={selectedInvites.length === 0 || isBulkRevokingInvites}
onClick={() => setBulkRevokeInvitesOpen(true)}
>
<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">
<span>Itens por página</span>
<Select
value={`${invitesPageSize}`}
onValueChange={(value) => {
const next = Number(value)
setInvitesPageSize(next)
setInvitesPageIndex(0)
}}
>
<SelectTrigger className="h-8 w-20">
<SelectValue placeholder={`${invitesPageSize}`} />
</SelectTrigger>
<SelectContent align="end">
{[10, 20, 30, 50].map((n) => (
<SelectItem key={`invites-page-${n}`} value={`${n}`}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
disabled={invitesPageIndex === 0}
onClick={() => setInvitesPageIndex((previous) => Math.max(0, previous - 1))}
/>
</div>
</td>
<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>
{/* Espaço removido */}
<td className="py-3 pr-4 text-neutral-600">{formatDate(invite.expiresAt)}</td>
<td className="py-3 pr-4">
<Badge
variant={inviteStatusVariant(invite.status)}
className="rounded-full px-3 py-1 text-xs font-medium"
>
{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" && canManageInvite(invite.role) ? (
<Button
variant="ghost"
size="sm"
className="text-red-600 transition-colors hover:bg-red-500/10"
onClick={() => setRevokeDialogInviteId(invite.id)}
disabled={revokingId === invite.id}
>
{revokingId === invite.id ? "Revogando..." : "Revogar"}
</Button>
) : null}
{invite.status === "revoked" && canReactivateInvitePolicy(invite) && canManageInvite(invite.role) ? (
<Button
variant="outline"
size="sm"
className="border-amber-400 text-amber-600 hover:bg-amber-50"
onClick={() => handleReactivate(invite)}
disabled={reactivatingId === invite.id}
>
{reactivatingId === invite.id ? "Reativando..." : "Reativar"}
</Button>
) : null}
</div>
</td>
</tr>
))}
{invites.length === 0 ? (
<tr>
<td colSpan={7} className="py-6 text-center text-neutral-500">
Nenhum convite emitido até o momento.
</td>
</tr>
) : null}
</tbody>
</table>
<div className="mt-3 flex items-center justify-end">
<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={selectedInvites.length === 0 || isBulkRevokingInvites}
onClick={() => setBulkRevokeInvitesOpen(true)}
>
<IconTrash className="size-4" /> Revogar selecionados
</Button>
</PaginationItem>
<PaginationItem>
<PaginationLink
href="#"
isActive
onClick={(event) => {
event.preventDefault()
}}
>
{invitesPageIndex + 1}
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext
disabled={invitesPageIndex >= invitesPageCount - 1}
onClick={() =>
setInvitesPageIndex((previous) => Math.min(invitesPageCount - 1, previous + 1))
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
</div>
</CardContent>
</Card>

View file

@ -758,7 +758,7 @@ function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableVi
<div className="space-y-3">
<div className="overflow-hidden rounded-lg border">
<Table className="w-full table-auto">
<TableHeader className="bg-muted border-b">
<TableHeader className="bg-slate-100/80 border-b border-slate-200">
<TableRow>
<TableHead>Empresa</TableHead>
<TableHead>Contratos ativos</TableHead>
@ -787,7 +787,7 @@ function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableVi
return (
<TableRow key={company.id} className="hover:bg-muted/40">
<TableCell className="align-middle">
<div className="space-y-1">
<div className="flex h-full flex-col justify-center gap-1">
<div className="flex items-center gap-2">
<p className="font-semibold text-foreground">{company.name}</p>
{company.isAvulso ? <Badge variant="outline">Avulso</Badge> : null}

View file

@ -91,6 +91,8 @@ const ROLE_LABEL: Record<AdminAccount["role"], string> = {
COLLABORATOR: "Colaborador",
}
const NO_CONTACT_VALUE = "__none__"
function createId(prefix: string) {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return `${prefix}-${crypto.randomUUID()}`
@ -612,16 +614,16 @@ function CompanySectionSheet({ editor, onClose, onUpdated }: CompanySectionSheet
return (
<Sheet open={open} onOpenChange={(value) => (!value ? onClose() : null)}>
<SheetContent className="flex w-full max-w-4xl flex-col gap-0 p-0">
<SheetHeader className="border-b border-border/60 px-6 py-4">
<SheetTitle className="text-base font-semibold">
<SheetContent className="flex w-full max-w-none flex-col gap-0 bg-background p-0 sm:max-w-[48rem] lg:max-w-[60rem] xl:max-w-[68rem]">
<SheetHeader className="border-b border-border/60 px-10 py-7">
<SheetTitle className="text-xl font-semibold">
{editor?.section === "contacts" ? "Contatos da empresa" : null}
{editor?.section === "locations" ? "Localizações e unidades" : null}
{editor?.section === "contracts" ? "Contratos ativos" : null}
</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-6">{content}</div>
<SheetFooter className="border-t border-border/60 px-6 py-4">
<div className="flex-1 overflow-y-auto px-10 py-8">{content}</div>
<SheetFooter className="border-t border-border/60 px-10 py-5">
<div className="flex w-full items-center justify-end">
{isSubmitting ? (
<span className="text-sm text-muted-foreground">Salvando...</span>
@ -674,11 +676,11 @@ function ContactsEditor({
return (
<FormProvider {...form}>
<form onSubmit={submit} className="space-y-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-foreground">Contatos estratégicos</p>
<p className="text-xs text-muted-foreground">
<form onSubmit={submit} className="space-y-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1.5">
<p className="text-base font-semibold text-foreground">Contatos estratégicos</p>
<p className="text-sm text-muted-foreground">
Cadastre responsáveis por aprovação, faturamento e comunicação.
</p>
</div>
@ -686,6 +688,7 @@ function ContactsEditor({
type="button"
variant="outline"
size="sm"
className="self-start sm:self-auto"
onClick={() =>
fieldArray.append({
id: createId("contact"),
@ -703,18 +706,18 @@ function ContactsEditor({
})
}
>
<IconPlus className="mr-1 size-3.5" />
<IconPlus className="mr-2 size-4" />
Novo contato
</Button>
</div>
<div className="space-y-4">
<div className="space-y-6">
{fieldArray.fields.map((field, index) => {
const errors = form.formState.errors.contacts?.[index]
return (
<Card key={field.id}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-base font-semibold">Contato #{index + 1}</CardTitle>
<Card key={field.id} className="border border-border/60 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="text-lg font-semibold">Contato #{index + 1}</CardTitle>
<Button
type="button"
variant="ghost"
@ -725,29 +728,39 @@ function ContactsEditor({
<IconTrash className="size-4" />
</Button>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div>
<Label>Nome completo</Label>
<CardContent className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Nome completo
</Label>
<Input {...form.register(`contacts.${index}.fullName` as const)} />
<FieldError message={errors?.fullName?.message} />
</div>
<div>
<Label>E-mail</Label>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
E-mail
</Label>
<Input {...form.register(`contacts.${index}.email` as const)} />
<FieldError message={errors?.email?.message} />
</div>
<div>
<Label>Telefone</Label>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Telefone
</Label>
<Input {...form.register(`contacts.${index}.phone` as const)} placeholder="(11) 99999-0000" />
<FieldError message={errors?.phone?.message as string | undefined} />
</div>
<div>
<Label>WhatsApp</Label>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
WhatsApp
</Label>
<Input {...form.register(`contacts.${index}.whatsapp` as const)} placeholder="(11) 99999-0000" />
<FieldError message={errors?.whatsapp?.message as string | undefined} />
</div>
<div>
<Label>Função</Label>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Função
</Label>
<Controller
name={`contacts.${index}.role` as const}
control={form.control}
@ -767,12 +780,16 @@ function ContactsEditor({
)}
/>
</div>
<div>
<Label>Cargo interno</Label>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Cargo interno
</Label>
<Input {...form.register(`contacts.${index}.title` as const)} placeholder="ex.: Coordenador TI" />
</div>
<div className="md:col-span-2">
<Label>Preferências de contato</Label>
<div className="md:col-span-2 space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Preferências de contato
</Label>
<Controller
name={`contacts.${index}.preference` as const}
control={form.control}
@ -785,26 +802,28 @@ function ContactsEditor({
)}
/>
</div>
<div className="flex items-center gap-3">
<div className="md:col-span-2 flex items-center gap-3 rounded-lg border border-border/60 bg-muted/30 px-4 py-3">
<Checkbox
checked={form.watch(`contacts.${index}.canAuthorizeTickets` as const)}
onCheckedChange={(checked) =>
form.setValue(`contacts.${index}.canAuthorizeTickets` as const, Boolean(checked))
}
/>
<span className="text-sm text-muted-foreground">Pode autorizar tickets</span>
<span className="text-sm font-medium text-neutral-700">Pode autorizar tickets</span>
</div>
<div className="flex items-center gap-3">
<div className="md:col-span-2 flex items-center gap-3 rounded-lg border border-border/60 bg-muted/30 px-4 py-3">
<Checkbox
checked={form.watch(`contacts.${index}.canApproveCosts` as const)}
onCheckedChange={(checked) =>
form.setValue(`contacts.${index}.canApproveCosts` as const, Boolean(checked))
}
/>
<span className="text-sm text-muted-foreground">Pode aprovar custos</span>
<span className="text-sm font-medium text-neutral-700">Pode aprovar custos</span>
</div>
<div className="md:col-span-2">
<Label>Anotações</Label>
<div className="md:col-span-2 space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Anotações
</Label>
<Textarea {...form.register(`contacts.${index}.notes` as const)} />
</div>
</CardContent>
@ -813,11 +832,11 @@ function ContactsEditor({
})}
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="ghost" onClick={onCancel}>
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
<Button type="button" variant="ghost" className="whitespace-nowrap sm:w-auto" onClick={onCancel}>
Cancelar
</Button>
<Button type="submit">
<Button type="submit" className="whitespace-nowrap sm:w-auto">
<IconPencil className="mr-2 size-4" />
Salvar contatos
</Button>
@ -862,18 +881,19 @@ function LocationsEditor({
return (
<FormProvider {...form}>
<form onSubmit={submit} className="space-y-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-foreground">Localizações</p>
<p className="text-xs text-muted-foreground">
Define unidades críticas, data centers e filiais para atendimento dedicado.
<form onSubmit={submit} className="space-y-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1.5">
<p className="text-base font-semibold text-foreground">Localizações</p>
<p className="text-sm text-muted-foreground">
Defina unidades críticas, data centers e filiais para atendimento dedicado.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="self-start sm:self-auto"
onClick={() =>
fieldArray.append({
id: createId("location"),
@ -886,18 +906,18 @@ function LocationsEditor({
})
}
>
<IconPlus className="mr-1 size-3.5" />
<IconPlus className="mr-2 size-4" />
Nova unidade
</Button>
</div>
<div className="space-y-4">
<div className="space-y-6">
{fieldArray.fields.map((field, index) => {
const errors = form.formState.errors.locations?.[index]
return (
<Card key={field.id}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-base font-semibold">Unidade #{index + 1}</CardTitle>
<Card key={field.id} className="border border-border/60 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="text-lg font-semibold">Unidade #{index + 1}</CardTitle>
<Button
type="button"
variant="ghost"
@ -908,14 +928,14 @@ function LocationsEditor({
<IconTrash className="size-4" />
</Button>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div>
<Label>Nome</Label>
<CardContent className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Nome</Label>
<Input {...form.register(`locations.${index}.name` as const)} />
<FieldError message={errors?.name?.message} />
</div>
<div>
<Label>Tipo</Label>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Tipo</Label>
<Controller
name={`locations.${index}.type` as const}
control={form.control}
@ -935,18 +955,25 @@ function LocationsEditor({
)}
/>
</div>
<div>
<Label>Contato responsável</Label>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Contato responsável
</Label>
<Controller
name={`locations.${index}.responsibleContactId` as const}
control={form.control}
render={({ field }) => (
<Select value={field.value ?? ""} onValueChange={(value) => field.onChange(value || null)}>
<Select
value={field.value ?? NO_CONTACT_VALUE}
onValueChange={(value) =>
field.onChange(value === NO_CONTACT_VALUE ? null : value)
}
>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Nenhum</SelectItem>
<SelectItem value={NO_CONTACT_VALUE}>Nenhum</SelectItem>
{contacts.map((contact) => (
<SelectItem key={contact.id} value={contact.id}>
{contact.fullName}
@ -957,8 +984,10 @@ function LocationsEditor({
)}
/>
</div>
<div>
<Label>Notas</Label>
<div className="md:col-span-2 space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Notas
</Label>
<Textarea {...form.register(`locations.${index}.notes` as const)} />
</div>
</CardContent>
@ -967,11 +996,11 @@ function LocationsEditor({
})}
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="ghost" onClick={onCancel}>
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
<Button type="button" variant="ghost" className="whitespace-nowrap sm:w-auto" onClick={onCancel}>
Cancelar
</Button>
<Button type="submit">
<Button type="submit" className="whitespace-nowrap sm:w-auto">
<IconPencil className="mr-2 size-4" />
Salvar localizações
</Button>
@ -1019,11 +1048,11 @@ function ContractsEditor({
return (
<FormProvider {...form}>
<form onSubmit={submit} className="space-y-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-foreground">Contratos</p>
<p className="text-xs text-muted-foreground">
<form onSubmit={submit} className="space-y-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1.5">
<p className="text-base font-semibold text-foreground">Contratos</p>
<p className="text-sm text-muted-foreground">
Registre vigência, escopo e condições para este cliente.
</p>
</div>
@ -1031,6 +1060,7 @@ function ContractsEditor({
type="button"
variant="outline"
size="sm"
className="self-start sm:self-auto"
onClick={() =>
fieldArray.append({
id: createId("contract"),
@ -1047,18 +1077,18 @@ function ContractsEditor({
})
}
>
<IconPlus className="mr-1 size-3.5" />
<IconPlus className="mr-2 size-4" />
Novo contrato
</Button>
</div>
<div className="space-y-4">
<div className="space-y-6">
{fieldArray.fields.map((field, index) => {
const errors = form.formState.errors.contracts?.[index]
return (
<Card key={field.id}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-base font-semibold">Contrato #{index + 1}</CardTitle>
<Card key={field.id} className="border border-border/60 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="text-lg font-semibold">Contrato #{index + 1}</CardTitle>
<Button
type="button"
variant="ghost"
@ -1069,9 +1099,11 @@ function ContractsEditor({
<IconTrash className="size-4" />
</Button>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div>
<Label>Tipo</Label>
<CardContent className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Tipo
</Label>
<Controller
name={`contracts.${index}.contractType` as const}
control={form.control}
@ -1091,34 +1123,46 @@ function ContractsEditor({
)}
/>
</div>
<div>
<Label>SKU/plano</Label>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
SKU/plano
</Label>
<Input {...form.register(`contracts.${index}.planSku` as const)} />
</div>
<div>
<Label>Início</Label>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Início
</Label>
<Input type="date" {...form.register(`contracts.${index}.startDate` as const)} />
<FieldError message={errors?.startDate?.message as string | undefined} />
</div>
<div>
<Label>Fim</Label>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Fim
</Label>
<Input type="date" {...form.register(`contracts.${index}.endDate` as const)} />
<FieldError message={errors?.endDate?.message as string | undefined} />
</div>
<div>
<Label>Renovação</Label>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Renovação
</Label>
<Input type="date" {...form.register(`contracts.${index}.renewalDate` as const)} />
</div>
<div>
<Label>Valor (R$)</Label>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Valor (R$)
</Label>
<Input
type="number"
step="0.01"
{...form.register(`contracts.${index}.price` as const, { valueAsNumber: true })}
/>
</div>
<div className="md:col-span-2">
<Label>Escopo</Label>
<div className="md:col-span-2 space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Escopo
</Label>
<Controller
name={`contracts.${index}.scope` as const}
control={form.control}
@ -1138,8 +1182,10 @@ function ContractsEditor({
)}
/>
</div>
<div className="md:col-span-2">
<Label>Observações</Label>
<div className="md:col-span-2 space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Observações
</Label>
<Textarea {...form.register(`contracts.${index}.notes` as const)} />
</div>
</CardContent>
@ -1148,11 +1194,11 @@ function ContractsEditor({
})}
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="ghost" onClick={onCancel}>
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
<Button type="button" variant="ghost" className="whitespace-nowrap sm:w-auto" onClick={onCancel}>
Cancelar
</Button>
<Button type="submit">
<Button type="submit" className="whitespace-nowrap sm:w-auto">
<IconPencil className="mr-2 size-4" />
Salvar contratos
</Button>

View file

@ -106,8 +106,8 @@ const navigation: NavigationGroup[] = [
url: "/admin/companies",
icon: Building2,
requiredRole: "admin",
children: [{ title: "Usuários", url: "/admin/users", icon: Users, requiredRole: "admin" }],
},
{ title: "Usuários", url: "/admin/users", icon: Users, requiredRole: "admin" },
{ title: "Máquinas", url: "/admin/machines", icon: MonitorCog, requiredRole: "admin" },
{ title: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" },
],

View file

@ -42,6 +42,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const isManager = normalizedRole === "manager"
const canStaffComment = hasAssignee || isManager
const canComment = isRequester || (isStaff && canStaffComment)
const allowTicketMentions = normalizedRole === "admin" || normalizedRole === "agent"
const addComment = useMutation(api.tickets.addComment)
const removeAttachment = useMutation(api.tickets.removeCommentAttachment)
const updateComment = useMutation(api.tickets.updateComment)
@ -303,6 +304,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
}
disabled={savingCommentId === commentId}
placeholder="Edite o comentário..."
ticketMention={{ enabled: allowTicketMentions }}
/>
<div className="mt-3 flex items-center justify-end gap-2">
<Button
@ -381,7 +383,14 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
</div>
)}
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
<RichTextEditor value={body} onChange={setBody} placeholder="Escreva um comentário..." className="rounded-2xl border border-slate-200" disabled={!canComment} />
<RichTextEditor
value={body}
onChange={setBody}
placeholder="Escreva um comentário..."
className="rounded-2xl border border-slate-200"
disabled={!canComment}
ticketMention={{ enabled: allowTicketMentions }}
/>
<Dropzone
onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])}
currentFileCount={attachmentsToSend.length}

View file

@ -208,6 +208,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const [pausing, setPausing] = useState(false)
const [exportingPdf, setExportingPdf] = useState(false)
const [closeOpen, setCloseOpen] = useState(false)
const [assigneeChangeReason, setAssigneeChangeReason] = useState("")
const [assigneeReasonError, setAssigneeReasonError] = useState<string | null>(null)
const selectedCategoryId = categorySelection.categoryId
const selectedSubcategoryId = categorySelection.subcategoryId
const dirty = useMemo(
@ -321,12 +323,25 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
setAssigneeSelection(currentAssigneeId)
throw new Error("assignee-not-allowed")
}
const reasonValue = assigneeChangeReason.trim()
if (reasonValue.length < 5) {
setAssigneeReasonError("Descreva o motivo com pelo menos 5 caracteres.")
toast.error("Informe um motivo para registrar a troca do responsável.", { id: "assignee" })
return
}
if (reasonValue.length > 1000) {
setAssigneeReasonError("Use no máximo 1.000 caracteres.")
toast.error("Reduza o motivo para até 1.000 caracteres.", { id: "assignee" })
return
}
setAssigneeReasonError(null)
toast.loading("Atualizando responsável...", { id: "assignee" })
try {
await changeAssignee({
ticketId: ticket.id as Id<"tickets">,
assigneeId: assigneeSelection as Id<"users">,
actorId: convexUserId as Id<"users">,
reason: reasonValue,
})
toast.success("Responsável atualizado!", { id: "assignee" })
if (assigneeSelection) {
@ -341,6 +356,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
})
}
}
setAssigneeChangeReason("")
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar o responsável.", { id: "assignee" })
@ -387,11 +403,15 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
})
setQueueSelection(currentQueueName)
setAssigneeSelection(currentAssigneeId)
setAssigneeChangeReason("")
setAssigneeReasonError(null)
setEditing(false)
}
useEffect(() => {
if (editing) return
setAssigneeChangeReason("")
setAssigneeReasonError(null)
setCategorySelection({
categoryId: ticket.category?.id ?? "",
subcategoryId: ticket.subcategory?.id ?? "",
@ -1097,6 +1117,28 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<span className={sectionValueClass}>{assigneeState?.name ?? "Não atribuído"}</span>
)}
</div>
{editing && assigneeDirty ? (
<div className="flex flex-col gap-2 sm:col-span-2 lg:col-span-3">
<span className={sectionLabelClass}>Motivo da troca</span>
<Textarea
value={assigneeChangeReason}
onChange={(event) => {
setAssigneeChangeReason(event.target.value)
if (assigneeReasonError) {
setAssigneeReasonError(null)
}
}}
placeholder="Explique brevemente por que o chamado será reatribuído..."
className="min-h-[96px]"
/>
<p className="text-xs text-neutral-500">
O motivo é registrado como comentário interno visível para administradores e agentes.
</p>
{assigneeReasonError ? (
<p className="text-xs font-semibold text-rose-600">{assigneeReasonError}</p>
) : null}
</div>
) : null}
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Criado em</span>
<span className={sectionValueClass}>{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>

View file

@ -1,11 +1,21 @@
"use client"
import { forwardRef, useCallback, useEffect, useRef, useState } from "react"
import {
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import type { ReactNode } from "react"
import { useEditor, EditorContent } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import Link from "@tiptap/extension-link"
import Placeholder from "@tiptap/extension-placeholder"
import Mention from "@tiptap/extension-mention"
import { ReactRenderer } from "@tiptap/react"
import tippy, { type Instance, type Props as TippyProps } from "tippy.js"
import { cn } from "@/lib/utils"
import sanitize from "sanitize-html"
import { Button } from "@/components/ui/button"
@ -33,8 +43,356 @@ type RichTextEditorProps = {
placeholder?: string
disabled?: boolean
minHeight?: number
ticketMention?: {
enabled?: boolean
}
}
type TicketMentionItem = {
id: string
reference: number
subject: string
status: string
priority: string
requesterName: string | null
assigneeName: string | null
companyName: string | null
url: string
updatedAt: string
}
type TicketMentionSuggestionProps = {
command: (item: TicketMentionItem) => void
items: TicketMentionItem[]
onRegister?: (handler: ((event: KeyboardEvent) => boolean) | null) => void
}
const TICKET_MENTION_CLASS = "ticket-mention"
function formatMentionSubject(subject: string) {
if (!subject) return ""
return subject.length > 60 ? `${subject.slice(0, 57)}` : subject
}
const statusLabels: Record<string, string> = {
PENDING: "Pendente",
AWAITING_ATTENDANCE: "Em andamento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
}
const statusTone: Record<string, string> = {
PENDING: "bg-amber-400",
AWAITING_ATTENDANCE: "bg-sky-500",
PAUSED: "bg-violet-500",
RESOLVED: "bg-emerald-500",
}
const priorityLabels: Record<string, string> = {
LOW: "Baixa",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Urgente",
}
const mentionCache = new Map<string, TicketMentionItem[]>()
let mentionAbortController: AbortController | null = null
async function fetchTicketMentions(query: string): Promise<TicketMentionItem[]> {
const cacheKey = query.trim().toLowerCase()
if (mentionCache.has(cacheKey)) {
return mentionCache.get(cacheKey) ?? []
}
try {
mentionAbortController?.abort()
mentionAbortController = new AbortController()
const response = await fetch(`/api/tickets/mentions?q=${encodeURIComponent(query)}`, {
signal: mentionAbortController.signal,
})
if (!response.ok) {
return []
}
const json = (await response.json()) as { items?: TicketMentionItem[] }
const items = Array.isArray(json.items) ? json.items : []
mentionCache.set(cacheKey, items)
return items
} catch (error) {
if ((error as Error).name === "AbortError") {
return []
}
return []
}
}
function TicketMentionList({ items, command, onRegister }: TicketMentionSuggestionProps) {
const [selectedIndex, setSelectedIndex] = useState(0)
const selectItem = useCallback(
(index: number) => {
const item = items[index]
if (item) {
command(item)
}
},
[command, items]
)
const upHandler = useCallback(() => {
setSelectedIndex((prev) => {
const nextIndex = (prev + items.length - 1) % items.length
return nextIndex
})
}, [items.length])
const downHandler = useCallback(() => {
setSelectedIndex((prev) => {
const nextIndex = (prev + 1) % items.length
return nextIndex
})
}, [items.length])
const enterHandler = useCallback(() => {
selectItem(selectedIndex)
}, [selectItem, selectedIndex])
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key === "ArrowUp") {
upHandler()
return true
}
if (event.key === "ArrowDown") {
downHandler()
return true
}
if (event.key === "Enter") {
enterHandler()
return true
}
return false
},
[downHandler, enterHandler, upHandler]
)
useEffect(() => {
onRegister?.(handleKeyDown)
return () => {
onRegister?.(null)
}
}, [handleKeyDown, onRegister])
useEffect(() => {
setSelectedIndex(0)
}, [items])
if (!items.length) {
return (
<div className="min-w-[260px] p-3 text-sm text-muted-foreground">
Nenhum chamado encontrado com esse termo.
</div>
)
}
return (
<div className="max-h-72 min-w-[320px] space-y-1 overflow-y-auto p-2">
{items.map((item, index) => {
const isActive = index === selectedIndex
const status = statusLabels[item.status] ?? item.status
const statusDot = statusTone[item.status] ?? "bg-slate-400"
const priority = priorityLabels[item.priority] ?? item.priority
return (
<button
key={item.id}
type="button"
className={cn(
"w-full rounded-lg border border-transparent px-3 py-2 text-left transition",
isActive ? "border-slate-200 bg-slate-100" : "hover:bg-slate-50"
)}
onMouseEnter={() => setSelectedIndex(index)}
onMouseDown={(event) => {
event.preventDefault()
selectItem(index)
}}
>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-neutral-900">#{item.reference}</span>
<span className="text-xs font-medium uppercase tracking-wide text-neutral-500">{priority}</span>
</div>
<div className="mt-1 line-clamp-1 text-sm text-neutral-700">{formatMentionSubject(item.subject)}</div>
<div className="mt-1 flex items-center gap-2 text-xs text-neutral-500">
<span className="inline-flex items-center gap-1">
<span className={cn("inline-flex size-2 rounded-full", statusDot)} />
{status}
</span>
{item.companyName ? <span> {item.companyName}</span> : null}
{item.assigneeName ? <span> {item.assigneeName}</span> : null}
</div>
</button>
)
})}
</div>
)
}
TicketMentionList.displayName = "TicketMentionList"
const TicketMentionListComponent = (props: TicketMentionSuggestionProps) => (
<TicketMentionList {...props} />
)
const TicketMentionExtension = Mention.extend({
name: "ticketMention",
addAttributes() {
return {
id: { default: null },
reference: { default: null },
subject: { default: null },
status: { default: null },
priority: { default: null },
url: { default: null },
}
},
parseHTML() {
return [
{
tag: `a[data-ticket-mention="true"]`,
getAttrs: (dom: HTMLElement | string) => {
if (dom instanceof HTMLElement) {
return {
id: dom.dataset.ticketId ?? null,
reference: dom.dataset.ticketReference ?? null,
status: dom.dataset.ticketStatus ?? null,
priority: dom.dataset.ticketPriority ?? null,
subject: dom.getAttribute("data-ticket-subject") ?? dom.textContent ?? null,
url: dom.getAttribute("href") ?? null,
}
}
return {}
},
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
const referenceValue =
HTMLAttributes.reference ?? HTMLAttributes["data-ticket-reference"] ?? ""
const reference = String(referenceValue ?? "")
const subjectValue =
HTMLAttributes.subject ?? HTMLAttributes["data-ticket-subject"] ?? ""
const subject = String(subjectValue ?? "")
const status = String(HTMLAttributes.status ?? HTMLAttributes["data-ticket-status"] ?? "PENDING").toUpperCase()
const priority = String(HTMLAttributes.priority ?? HTMLAttributes["data-ticket-priority"] ?? "MEDIUM").toUpperCase()
const href = String(HTMLAttributes.url ?? HTMLAttributes.href ?? "#")
return [
"a",
{
...HTMLAttributes,
href,
"data-ticket-mention": "true",
"data-ticket-id": HTMLAttributes.id ?? HTMLAttributes["data-ticket-id"] ?? "",
"data-ticket-reference": reference ?? "",
"data-ticket-status": status,
"data-ticket-priority": priority,
"data-ticket-subject": subject ?? "",
class: TICKET_MENTION_CLASS,
},
[
"span",
{ class: "ticket-mention-dot" },
],
[
"span",
{ class: "ticket-mention-ref" },
`#${reference ?? ""}`,
],
[
"span",
{ class: "ticket-mention-sep" },
"•",
],
[
"span",
{ class: "ticket-mention-subject" },
formatMentionSubject(subject ?? ""),
],
]
},
renderLabel({ node }: { node: { attrs: Record<string, unknown> } }) {
const subjectAttr = node.attrs.subject ?? node.attrs["data-ticket-subject"] ?? ""
const displayedSubject = typeof subjectAttr === "string" ? subjectAttr : String(subjectAttr ?? "")
const refAttr = node.attrs.reference ?? node.attrs.id ?? node.attrs["data-ticket-reference"] ?? ""
const reference = typeof refAttr === "string" ? refAttr : String(refAttr ?? "")
const subjectPart = displayedSubject ? `${formatMentionSubject(displayedSubject)}` : ""
return `#${reference}${subjectPart}`
},
}).configure({
suggestion: {
char: "@",
startOfLine: false,
allowSpaces: false,
render: () => {
let component: ReactRenderer | null = null
let popup: Instance<TippyProps> | null = null
let keydownHandler: ((event: KeyboardEvent) => boolean) | null = null
const registerHandler = (handler: ((event: KeyboardEvent) => boolean) | null) => {
keydownHandler = handler
}
return {
onStart: (props) => {
const listProps: TicketMentionSuggestionProps = {
command: props.command,
items: props.items,
onRegister: registerHandler,
}
component = new ReactRenderer(TicketMentionListComponent, {
props: listProps,
editor: props.editor,
})
if (!props.clientRect) return
popup = tippy(document.body, {
getReferenceClientRect: () => props.clientRect?.() ?? new DOMRect(),
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
})
},
onUpdate(props) {
component?.updateProps({
command: props.command,
items: props.items,
onRegister: registerHandler,
})
if (!props.clientRect) return
popup?.setProps({
getReferenceClientRect: () => props.clientRect?.() ?? new DOMRect(),
})
},
onKeyDown(props) {
if (props.event.key === "Escape") {
popup?.hide()
return true
}
if (keydownHandler && keydownHandler(props.event)) {
return true
}
return false
},
onExit() {
popup?.destroy()
component?.destroy()
keydownHandler = null
},
}
},
items: async ({ query }) => {
return fetchTicketMentions(query)
},
},
})
export function RichTextEditor({
value,
onChange,
@ -42,9 +400,10 @@ export function RichTextEditor({
placeholder = "Escreva aqui...",
disabled,
minHeight = 120,
ticketMention,
}: RichTextEditorProps) {
const editor = useEditor({
extensions: [
const extensions = useMemo(() => {
const baseExtensions = [
StarterKit.configure({
bulletList: { keepMarks: true },
orderedList: { keepMarks: true },
@ -56,6 +415,13 @@ export function RichTextEditor({
HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
}),
Placeholder.configure({ placeholder }),
]
return ticketMention?.enabled ? [...baseExtensions, TicketMentionExtension] : baseExtensions
}, [placeholder, ticketMention?.enabled])
const editor = useEditor({
extensions: [
...extensions,
],
editorProps: {
attributes: {
@ -300,15 +666,48 @@ export function sanitizeEditorHtml(html: string): string {
"p","br","a","strong","em","u","s","blockquote","ul","ol","li","code","pre","span","h1","h2","h3"
],
allowedAttributes: {
a: ["href","name","target","rel"],
span: ["style"],
a: [
"href",
"name",
"target",
"rel",
"class",
"data-ticket-mention",
"data-ticket-id",
"data-ticket-reference",
"data-ticket-status",
"data-ticket-priority",
"data-ticket-subject",
"title",
],
span: [
"style",
"class",
"data-ticket-mention",
"data-ticket-id",
"data-ticket-reference",
"data-ticket-status",
"data-ticket-priority",
"data-ticket-subject",
],
code: ["class"],
pre: ["class"],
},
allowedSchemes: ["http","https","mailto"],
// prevent target=_self phishing
transformTags: {
a: sanitize.simpleTransform("a", { rel: "noopener noreferrer", target: "_blank" }, true),
a: (tagName, attribs) => {
const isMention = attribs["data-ticket-mention"] === "true"
const nextAttribs = {
...attribs,
rel: attribs.rel ?? "noopener noreferrer",
target: isMention ? "_self" : "_blank",
}
return {
tagName,
attribs: nextAttribs,
}
},
},
// disallow inline event handlers
allowVulnerableTags: false,