Align admin tables with ticket styling and add board view

This commit is contained in:
Esdras Renan 2025-10-24 12:23:27 -03:00
parent 63cf9f9d45
commit a319aa0eff
8 changed files with 783 additions and 447 deletions

View file

@ -28,6 +28,14 @@ import {
PaginationNext, PaginationNext,
PaginationPrevious, PaginationPrevious,
} from "@/components/ui/pagination" } from "@/components/ui/pagination"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet" import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useQuery } from "convex/react" import { useQuery } from "convex/react"
@ -35,6 +43,7 @@ import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"
import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz" import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
import { canReactivateInvite as canReactivateInvitePolicy } from "@/lib/invite-policies" import { canReactivateInvite as canReactivateInvitePolicy } from "@/lib/invite-policies"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
type AdminRole = RoleOption | "machine" type AdminRole = RoleOption | "machine"
const NO_COMPANY_ID = "__none__" const NO_COMPANY_ID = "__none__"
@ -284,6 +293,31 @@ export function AdminUsersManager({
) )
const machineUsers = useMemo(() => users.filter((user) => user.role === "machine"), [users]) const machineUsers = useMemo(() => users.filter((user) => user.role === "machine"), [users])
const teamRoleFilterOptions = useMemo<SearchableComboboxOption[]>(() => {
const teamRoles = selectableRoles.filter((option) => ["admin", "agent"].includes(option))
return [
{ value: "all", label: "Todos os papéis" },
...teamRoles.map((option) => ({ value: option, label: formatRole(option) })),
]
}, [selectableRoles])
const usersTypeFilterOptions = useMemo<SearchableComboboxOption[]>(
() => [
{ value: "all", label: "Todos" },
{ value: "people", label: "Pessoas" },
{ value: "machines", label: "Máquinas" },
],
[],
)
const companyFilterOptions = useMemo<SearchableComboboxOption[]>(
() => [
{ value: "all", label: "Todas as empresas" },
...companies.map((company) => ({ value: company.id, label: company.name })),
],
[companies],
)
const defaultCreateRole: RoleOption = selectableRoles[0] ?? "agent" const defaultCreateRole: RoleOption = selectableRoles[0] ?? "agent"
// Equipe // Equipe
const [teamSearch, setTeamSearch] = useState("") const [teamSearch, setTeamSearch] = useState("")
@ -1158,28 +1192,28 @@ async function handleDeleteUser() {
/> />
</div> </div>
<div className="mt-3 flex flex-wrap items-center gap-3 md:mt-0"> <div className="mt-3 flex flex-wrap items-center gap-3 md:mt-0">
<Select value={teamRoleFilter} onValueChange={(value) => setTeamRoleFilter(value as "all" | RoleOption)}> <SearchableCombobox
<SelectTrigger className="h-9 w-full sm:w-48"> value={teamRoleFilter}
<SelectValue placeholder="Todos os papéis" /> onValueChange={(value) => {
</SelectTrigger> if (value === "admin" || value === "agent") {
<SelectContent> setTeamRoleFilter(value)
<SelectItem value="all">Todos os papéis</SelectItem> return
{selectableRoles.filter((option) => ["admin", "agent"].includes(option)).map((option) => ( }
<SelectItem key={`team-filter-${option}`} value={option}>{formatRole(option)}</SelectItem> setTeamRoleFilter("all")
))} }}
</SelectContent> options={teamRoleFilterOptions}
</Select> placeholder="Todos os papéis"
<Select value={teamCompanyFilter} onValueChange={setTeamCompanyFilter}> searchPlaceholder="Buscar papel..."
<SelectTrigger className="h-9 w-full sm:w-56"> className="md:w-48"
<SelectValue placeholder="Empresa" /> />
</SelectTrigger> <SearchableCombobox
<SelectContent> value={teamCompanyFilter}
<SelectItem value="all">Todas as empresas</SelectItem> onValueChange={(value) => setTeamCompanyFilter(value ?? "all")}
{companies.map((company) => ( options={companyFilterOptions}
<SelectItem key={`team-company-${company.id}`} value={company.id}>{company.name}</SelectItem> placeholder="Todas as empresas"
))} searchPlaceholder="Buscar empresa..."
</SelectContent> className="md:w-56"
</Select> />
{/* Filtro por espaço removido */} {/* Filtro por espaço removido */}
{(teamSearch.trim().length > 0 || teamRoleFilter !== "all") ? ( {(teamSearch.trim().length > 0 || teamRoleFilter !== "all") ? (
<Button <Button
@ -1214,107 +1248,105 @@ async function handleDeleteUser() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="w-full overflow-x-auto"> <div className="w-full overflow-x-auto">
<div className="overflow-hidden rounded-lg border"> <div className="overflow-hidden rounded-lg border">
<table className="min-w-full table-fixed text-sm"> <Table className="min-w-[960px] table-fixed text-sm">
<thead className="bg-slate-100/80"> <TableHeader className="bg-slate-100/80">
<tr className="text-left text-xs uppercase tracking-wide text-neutral-600"> <TableRow className="text-xs uppercase tracking-wide text-neutral-500">
<th className="w-10 px-4 py-3 font-semibold first:pl-6"> <TableHead className="w-12 px-4">
<div className="flex items-center justify-center"> <Checkbox
<Checkbox checked={allTeamSelected || (someTeamSelected && "indeterminate")}
checked={allTeamSelected || (someTeamSelected && "indeterminate")} onCheckedChange={(value) => toggleTeamSelectAll(!!value)}
onCheckedChange={(value) => toggleTeamSelectAll(!!value)} aria-label="Selecionar todos"
aria-label="Selecionar todos" />
/> </TableHead>
</div> <TableHead className="px-4">Nome</TableHead>
</th> <TableHead className="px-4">E-mail</TableHead>
<th className="px-4 py-3 font-semibold text-neutral-600 first:pl-6">Nome</th> <TableHead className="px-4">Papel</TableHead>
<th className="px-4 py-3 font-semibold text-neutral-600">E-mail</th> <TableHead className="px-4">Empresa</TableHead>
<th className="px-4 py-3 font-semibold text-neutral-600">Papel</th> <TableHead className="px-4">Máquinas</TableHead>
<th className="px-4 py-3 font-semibold text-neutral-600">Empresa</th> <TableHead className="px-4">Criado em</TableHead>
<th className="px-4 py-3 font-semibold text-neutral-600">Máquinas</th> <TableHead className="px-4 text-right">Ações</TableHead>
<th className="px-4 py-3 font-semibold text-neutral-600">Criado em</th> </TableRow>
<th className="px-4 py-3 font-semibold text-neutral-600 last:pr-6">Ações</th> </TableHeader>
</tr> <TableBody className="bg-white">
</thead> {teamPaginated.length > 0 ? (
<tbody className="divide-y divide-slate-100 bg-white"> teamPaginated.map((user) => (
{teamPaginated.length > 0 ? ( <TableRow key={user.id} className="hover:bg-slate-100/70">
teamPaginated.map((user) => ( <TableCell className="w-12 px-4">
<tr key={user.id} className="transition-colors hover:bg-slate-50/80"> <div className="flex items-center justify-center">
<td className="px-4 py-3 first:pl-6"> <Checkbox
<div className="flex items-center justify-center"> checked={teamSelection.has(user.id)}
<Checkbox onCheckedChange={(checked) => {
checked={teamSelection.has(user.id)} setTeamSelection((prev) => {
onCheckedChange={(checked) => { const next = new Set(prev)
setTeamSelection((prev) => { if (checked) next.add(user.id)
const next = new Set(prev) else next.delete(user.id)
if (checked) next.add(user.id) return next
else next.delete(user.id) })
return next }}
}) aria-label="Selecionar linha"
}} />
aria-label="Selecionar linha" </div>
/> </TableCell>
</div> <TableCell className="px-4 font-medium text-neutral-800">{user.name || "—"}</TableCell>
</td> <TableCell className="px-4 text-neutral-600">{user.email}</TableCell>
<td className="px-4 py-3 font-medium text-neutral-800 first:pl-6">{user.name || "—"}</td> <TableCell className="px-4 text-neutral-600">{formatRole(user.role)}</TableCell>
<td className="px-4 py-3 text-neutral-600">{user.email}</td> <TableCell className="px-4 text-neutral-600">{user.companyName ?? "—"}</TableCell>
<td className="px-4 py-3 text-neutral-600">{formatRole(user.role)}</td> <TableCell className="px-4 text-neutral-600">
<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 list = machinesByUserEmail.get((user.email ?? "").toLowerCase()) ?? [] const names = list.map((m) => m.hostname || m.id)
if (list.length === 0) return "—" const head = names.slice(0, 2).join(", ")
const names = list.map((m) => m.hostname || m.id) const extra = names.length > 2 ? ` +${names.length - 2}` : ""
const head = names.slice(0, 2).join(", ") return (
const extra = names.length > 2 ? ` +${names.length - 2}` : "" <span className="text-xs font-medium" title={names.join(", ")}>
return ( {head}
<span className="text-xs font-medium" title={names.join(", ")}> {extra}
{head} </span>
{extra} )
</span> })()}
) </TableCell>
})()} <TableCell className="px-4 text-neutral-500">{formatDate(user.createdAt)}</TableCell>
</td> <TableCell className="px-4">
<td className="px-4 py-3 text-neutral-500">{formatDate(user.createdAt)}</td> <div className="flex flex-wrap justify-end gap-2">
<td className="px-4 py-3 last:pr-6"> <Button
<div className="flex flex-wrap gap-2"> variant="outline"
<Button size="sm"
variant="outline" disabled={!canManageUser(user.role)}
size="sm" onClick={() => {
disabled={!canManageUser(user.role)} if (!canManageUser(user.role)) return
onClick={() => { setEditUserId(user.id)
if (!canManageUser(user.role)) return }}
setEditUserId(user.id) >
}} Editar
> </Button>
Editar <Button
</Button> variant="outline"
<Button size="sm"
variant="outline" className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
size="sm" disabled={!canManageUser(user.role)}
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700" onClick={() => {
disabled={!canManageUser(user.role)} if (!canManageUser(user.role)) return
onClick={() => { setDeleteUserId(user.id)
if (!canManageUser(user.role)) return }}
setDeleteUserId(user.id) >
}} Remover
> </Button>
Remover </div>
</Button> </TableCell>
</div> </TableRow>
</td> ))
</tr> ) : (
)) <TableRow>
) : ( <TableCell colSpan={8} className="px-6 py-6 text-center text-sm text-neutral-500">
<tr> {teamUsers.length === 0
<td colSpan={8} className="px-6 py-6 text-center text-sm text-neutral-500"> ? "Nenhum usuário cadastrado até o momento."
{teamUsers.length === 0 : "Nenhum usuário corresponde aos filtros atuais."}
? "Nenhum usuário cadastrado até o momento." </TableCell>
: "Nenhum usuário corresponde aos filtros atuais."} </TableRow>
</td> )}
</tr> </TableBody>
)} </Table>
</tbody>
</table>
</div> </div>
</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 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">
@ -1443,27 +1475,28 @@ async function handleDeleteUser() {
/> />
</div> </div>
<div className="mt-3 flex flex-wrap items-center gap-3 md:mt-0"> <div className="mt-3 flex flex-wrap items-center gap-3 md:mt-0">
<Select value={usersTypeFilter} onValueChange={(v) => setUsersTypeFilter(v as typeof usersTypeFilter)}> <SearchableCombobox
<SelectTrigger className="h-9 w-full sm:w-40"> value={usersTypeFilter}
<SelectValue placeholder="Tipo" /> onValueChange={(value) => {
</SelectTrigger> if (value === "people" || value === "machines") {
<SelectContent> setUsersTypeFilter(value)
<SelectItem value="all">Todos</SelectItem> return
<SelectItem value="people">Pessoas</SelectItem> }
<SelectItem value="machines">Máquinas</SelectItem> setUsersTypeFilter("all")
</SelectContent> }}
</Select> options={usersTypeFilterOptions}
<Select value={usersCompanyFilter} onValueChange={setUsersCompanyFilter}> placeholder="Todos"
<SelectTrigger className="h-9 w-full sm:w-56"> searchPlaceholder="Buscar tipo..."
<SelectValue placeholder="Empresa" /> className="md:w-40"
</SelectTrigger> />
<SelectContent> <SearchableCombobox
<SelectItem value="all">Todas as empresas</SelectItem> value={usersCompanyFilter}
{companies.map((company) => ( onValueChange={(value) => setUsersCompanyFilter(value ?? "all")}
<SelectItem key={`users-company-${company.id}`} value={company.id}>{company.name}</SelectItem> options={companyFilterOptions}
))} placeholder="Todas as empresas"
</SelectContent> searchPlaceholder="Buscar empresa..."
</Select> className="md:w-56"
/>
{/* Filtro por espaço removido */} {/* Filtro por espaço removido */}
{(usersSearch.trim().length > 0 || usersTypeFilter !== "all" || usersCompanyFilter !== "all") ? ( {(usersSearch.trim().length > 0 || usersTypeFilter !== "all" || usersCompanyFilter !== "all") ? (
<Button <Button
@ -1498,132 +1531,129 @@ async function handleDeleteUser() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="w-full overflow-x-auto"> <div className="w-full overflow-x-auto">
<div className="overflow-hidden rounded-lg border"> <div className="overflow-hidden rounded-lg border">
<table className="min-w-full table-fixed text-sm"> <Table className="min-w-[960px] table-fixed text-sm">
<thead className="bg-slate-100/80"> <TableHeader className="bg-slate-100/80">
<tr className="text-left text-xs uppercase tracking-wide text-neutral-600"> <TableRow className="text-xs uppercase tracking-wide text-neutral-500">
<th className="w-10 px-4 py-3 font-semibold first:pl-6"> <TableHead className="w-12 px-4">
<div className="flex items-center justify-center"> <Checkbox
<Checkbox checked={
checked={ usersSelection.size > 0 && usersSelection.size === filteredCombinedUsers.length
usersSelection.size > 0 && ? true
usersSelection.size === filteredCombinedUsers.length : usersSelection.size > 0
? true ? "indeterminate"
: usersSelection.size > 0 : false
? "indeterminate" }
: false onCheckedChange={(value) => toggleUsersCombinedSelectAll(!!value)}
} aria-label="Selecionar todos"
onCheckedChange={(value) => toggleUsersCombinedSelectAll(!!value)} />
aria-label="Selecionar todos" </TableHead>
/> <TableHead className="px-4">Nome</TableHead>
</div> <TableHead className="px-4">E-mail</TableHead>
</th> <TableHead className="px-4">Tipo</TableHead>
<th className="px-4 py-3 font-semibold text-neutral-600 first:pl-6">Nome</th> <TableHead className="px-4">Perfil</TableHead>
<th className="px-4 py-3 font-semibold text-neutral-600">E-mail</th> <TableHead className="px-4">Empresa</TableHead>
<th className="px-4 py-3 font-semibold text-neutral-600">Tipo</th> <TableHead className="px-4">Criado em</TableHead>
<th className="px-4 py-3 font-semibold text-neutral-600">Perfil</th> <TableHead className="px-4 text-right">Ações</TableHead>
<th className="px-4 py-3 font-semibold text-neutral-600">Empresa</th> </TableRow>
<th className="px-4 py-3 font-semibold text-neutral-600">Criado em</th> </TableHeader>
<th className="px-4 py-3 font-semibold text-neutral-600 last:pr-6">Ações</th> <TableBody className="bg-white">
</tr> {usersPaginated.length > 0 ? (
</thead> usersPaginated.map((user) => (
<tbody className="divide-y divide-slate-100 bg-white"> <TableRow key={user.id} className="hover:bg-slate-100/70">
{usersPaginated.length > 0 ? ( <TableCell className="w-12 px-4">
usersPaginated.map((user) => ( <div className="flex items-center justify-center">
<tr key={user.id} className="transition-colors hover:bg-slate-50/80"> <Checkbox
<td className="px-4 py-3 first:pl-6"> checked={usersSelection.has(user.id)}
<div className="flex items-center justify-center"> onCheckedChange={(checked) => {
<Checkbox setUsersSelection((prev) => {
checked={usersSelection.has(user.id)} const next = new Set(prev)
onCheckedChange={(checked) => { if (checked) next.add(user.id)
setUsersSelection((prev) => { else next.delete(user.id)
const next = new Set(prev) return next
if (checked) next.add(user.id) })
else next.delete(user.id) }}
return next aria-label="Selecionar linha"
}) />
}} </div>
aria-label="Selecionar linha" </TableCell>
/> <TableCell className="px-4 font-medium text-neutral-800">
</div> {user.name || (user.role === "machine" ? "Máquina" : "—")}
</td> </TableCell>
<td className="px-4 py-3 font-medium text-neutral-800 first:pl-6"> <TableCell className="px-4 text-neutral-600">{user.email}</TableCell>
{user.name || (user.role === "machine" ? "Máquina" : "—")} <TableCell className="px-4 text-neutral-600">
</td> {user.role === "machine" ? "Máquina" : "Pessoa"}
<td className="px-4 py-3 text-neutral-600">{user.email}</td> </TableCell>
<td className="px-4 py-3 text-neutral-600"> <TableCell className="px-4 text-neutral-600">
{user.role === "machine" ? "Máquina" : "Pessoa"} {user.role === "machine" ? (
</td> user.machinePersona ? (
<td className="px-4 py-3 text-neutral-600"> <Badge
{user.role === "machine" ? ( variant={machinePersonaBadgeVariant(user.machinePersona)}
user.machinePersona ? ( className="rounded-full px-3 py-1 text-xs font-medium"
<Badge >
variant={machinePersonaBadgeVariant(user.machinePersona)} {formatMachinePersona(user.machinePersona)}
className="rounded-full px-3 py-1 text-xs font-medium" </Badge>
>
{formatMachinePersona(user.machinePersona)}
</Badge>
) : (
<span className="text-neutral-500">Sem persona</span>
)
) : ( ) : (
formatRole(user.role) <span className="text-neutral-500">Sem persona</span>
)} )
</td> ) : (
<td className="px-4 py-3 text-neutral-600">{user.companyName ?? "—"}</td> formatRole(user.role)
<td className="px-4 py-3 text-neutral-500">{formatDate(user.createdAt)}</td> )}
<td className="px-4 py-3 last:pr-6"> </TableCell>
<div className="flex flex-wrap gap-2"> <TableCell className="px-4 text-neutral-600">{user.companyName ?? "—"}</TableCell>
<Button <TableCell className="px-4 text-neutral-500">{formatDate(user.createdAt)}</TableCell>
variant="outline" <TableCell className="px-4">
size="sm" <div className="flex flex-wrap justify-end gap-2">
disabled={!canManageUser(user.role)} <Button
onClick={() => { variant="outline"
if (!canManageUser(user.role)) return size="sm"
setEditUserId(user.id) disabled={!canManageUser(user.role)}
}} onClick={() => {
> if (!canManageUser(user.role)) return
Editar 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> </Button>
{user.role === "machine" ? ( ) : null}
<Button variant="ghost" size="sm" asChild> <Button
<Link variant="outline"
href={ size="sm"
extractMachineId(user.email) className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
? `/admin/machines/${extractMachineId(user.email)}` disabled={!canManageUser(user.role)}
: "/admin/machines" onClick={() => {
} if (!canManageUser(user.role)) return
> setDeleteUserId(user.id)
Detalhes da máquina }}
</Link> >
</Button> Remover
) : null} </Button>
<Button </div>
variant="outline" </TableCell>
size="sm" </TableRow>
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700" ))
disabled={!canManageUser(user.role)} ) : (
onClick={() => { <TableRow>
if (!canManageUser(user.role)) return <TableCell colSpan={8} className="px-6 py-6 text-center text-sm text-neutral-500">
setDeleteUserId(user.id) {combinedBaseUsers.length === 0
}} ? "Nenhum usuário cadastrado até o momento."
> : "Nenhum usuário corresponde aos filtros atuais."}
Remover </TableCell>
</Button> </TableRow>
</div> )}
</td> </TableBody>
</tr> </Table>
))
) : (
<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> </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 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">
@ -1777,101 +1807,99 @@ async function handleDeleteUser() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="w-full overflow-x-auto"> <div className="w-full overflow-x-auto">
<div className="overflow-hidden rounded-lg border"> <div className="overflow-hidden rounded-lg border">
<table className="min-w-full table-fixed text-sm"> <Table className="min-w-[800px] table-fixed text-sm">
<thead className="bg-slate-100/80"> <TableHeader className="bg-slate-100/80">
<tr className="text-left text-xs uppercase tracking-wide text-neutral-600"> <TableRow className="text-xs uppercase tracking-wide text-neutral-500">
<th className="w-10 px-4 py-3 font-semibold first:pl-6"> <TableHead className="w-12 px-4">
<div className="flex items-center justify-center"> <Checkbox
<Checkbox checked={allInvitesSelected || (someInvitesSelected && "indeterminate")}
checked={allInvitesSelected || (someInvitesSelected && "indeterminate")} onCheckedChange={(value) => toggleInvitesSelectAll(!!value)}
onCheckedChange={(value) => toggleInvitesSelectAll(!!value)} aria-label="Selecionar todos"
aria-label="Selecionar todos" />
/> </TableHead>
</div> <TableHead className="px-4">Colaborador</TableHead>
</th> <TableHead className="px-4">Papel</TableHead>
<th className="px-4 py-3 font-semibold text-neutral-600 first:pl-6">Colaborador</th> <TableHead className="px-4">Expira em</TableHead>
<th className="px-4 py-3 font-semibold text-neutral-600">Papel</th> <TableHead className="px-4">Status</TableHead>
<th className="px-4 py-3 font-semibold text-neutral-600">Expira em</th> <TableHead className="px-4 text-right">Ações</TableHead>
<th className="px-4 py-3 font-semibold text-neutral-600">Status</th> </TableRow>
<th className="px-4 py-3 font-semibold text-neutral-600 last:pr-6">Ações</th> </TableHeader>
</tr> <TableBody className="bg-white">
</thead> {paginatedInvites.length > 0 ? (
<tbody className="divide-y divide-slate-100 bg-white"> paginatedInvites.map((invite) => (
{paginatedInvites.length > 0 ? ( <TableRow key={invite.id} className="hover:bg-slate-100/70">
paginatedInvites.map((invite) => ( <TableCell className="w-12 px-4">
<tr key={invite.id} className="transition-colors hover:bg-slate-50/80"> <div className="flex items-center justify-center">
<td className="px-4 py-3 first:pl-6"> <Checkbox
<div className="flex items-center justify-center"> checked={inviteSelection.has(invite.id)}
<Checkbox onCheckedChange={(checked) => {
checked={inviteSelection.has(invite.id)} setInviteSelection((prev) => {
onCheckedChange={(checked) => { const next = new Set(prev)
setInviteSelection((prev) => { if (checked) next.add(invite.id)
const next = new Set(prev) else next.delete(invite.id)
if (checked) next.add(invite.id) return next
else next.delete(invite.id) })
return next }}
}) aria-label="Selecionar linha"
}} />
aria-label="Selecionar linha" </div>
/> </TableCell>
</div> <TableCell className="px-4">
</td> <div className="flex flex-col">
<td className="px-4 py-3 first:pl-6"> <span className="font-medium text-neutral-800">{invite.name || invite.email}</span>
<div className="flex flex-col"> <span className="text-xs text-neutral-500">{invite.email}</span>
<span className="font-medium text-neutral-800">{invite.name || invite.email}</span> </div>
<span className="text-xs text-neutral-500">{invite.email}</span> </TableCell>
</div> <TableCell className="px-4 text-neutral-600">{formatRole(invite.role)}</TableCell>
</td> <TableCell className="px-4 text-neutral-600">{formatDate(invite.expiresAt)}</TableCell>
<td className="px-4 py-3 text-neutral-600">{formatRole(invite.role)}</td> <TableCell className="px-4">
<td className="px-4 py-3 text-neutral-600">{formatDate(invite.expiresAt)}</td> <Badge
<td className="px-4 py-3"> variant={inviteStatusVariant(invite.status)}
<Badge className="rounded-full px-3 py-1 text-xs font-medium"
variant={inviteStatusVariant(invite.status)} >
className="rounded-full px-3 py-1 text-xs font-medium" {formatStatus(invite.status)}
> </Badge>
{formatStatus(invite.status)} </TableCell>
</Badge> <TableCell className="px-4">
</td> <div className="flex flex-wrap justify-end gap-2">
<td className="px-4 py-3 last:pr-6"> <Button variant="outline" size="sm" onClick={() => handleCopy(invite.inviteUrl)}>
<div className="flex flex-wrap gap-2"> Copiar link
<Button variant="outline" size="sm" onClick={() => handleCopy(invite.inviteUrl)}> </Button>
Copiar link {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> </Button>
{invite.status === "pending" && canManageInvite(invite.role) ? ( ) : null}
<Button {invite.status === "revoked" && canReactivateInvitePolicy(invite) && canManageInvite(invite.role) ? (
variant="ghost" <Button
size="sm" variant="outline"
className="text-red-600 transition-colors hover:bg-red-500/10" size="sm"
onClick={() => setRevokeDialogInviteId(invite.id)} className="border-amber-400 text-amber-600 hover:bg-amber-50"
disabled={revokingId === invite.id} onClick={() => handleReactivate(invite)}
> disabled={reactivatingId === invite.id}
{revokingId === invite.id ? "Revogando..." : "Revogar"} >
</Button> {reactivatingId === invite.id ? "Reativando..." : "Reativar"}
) : null} </Button>
{invite.status === "revoked" && canReactivateInvitePolicy(invite) && canManageInvite(invite.role) ? ( ) : null}
<Button </div>
variant="outline" </TableCell>
size="sm" </TableRow>
className="border-amber-400 text-amber-600 hover:bg-amber-50" ))
onClick={() => handleReactivate(invite)} ) : (
disabled={reactivatingId === invite.id} <TableRow>
> <TableCell colSpan={6} className="px-6 py-6 text-center text-sm text-neutral-500">
{reactivatingId === invite.id ? "Reativando..." : "Reativar"} Nenhum convite emitido até o momento.
</Button> </TableCell>
) : null} </TableRow>
</div> )}
</td> </TableBody>
</tr> </Table>
))
) : (
<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> </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 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">

View file

@ -182,6 +182,14 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
[rowSelection] [rowSelection]
) )
const visibleSelectedCount = useMemo(
() => filteredAccounts.reduce((total, account) => total + (rowSelection[account.id] ? 1 : 0), 0),
[filteredAccounts, rowSelection],
)
const allVisibleSelected = filteredAccounts.length > 0 && visibleSelectedCount === filteredAccounts.length
const isVisibleIndeterminate = visibleSelectedCount > 0 && visibleSelectedCount < filteredAccounts.length
const editAccount = useMemo( const editAccount = useMemo(
() => (editAccountId ? accounts.find((account) => account.id === editAccountId) ?? null : null), () => (editAccountId ? accounts.find((account) => account.id === editAccountId) ?? null : null),
[accounts, editAccountId] [accounts, editAccountId]
@ -229,6 +237,19 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
const closeDeleteDialog = useCallback(() => setDeleteDialogIds([]), []) const closeDeleteDialog = useCallback(() => setDeleteDialogIds([]), [])
const toggleVisibleSelection = useCallback(
(checked: boolean) => {
setRowSelection((prev) => {
const next = { ...prev }
filteredAccounts.forEach((account) => {
next[account.id] = checked
})
return next
})
},
[filteredAccounts],
)
const closeEditor = useCallback(() => { const closeEditor = useCallback(() => {
setEditAccountId(null) setEditAccountId(null)
setEditAuthUserId(null) setEditAuthUserId(null)
@ -448,12 +469,18 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
<Table className="w-full table-fixed text-sm"> <Table className="w-full table-fixed text-sm">
<TableHeader className="bg-muted"> <TableHeader className="bg-muted">
<TableRow> <TableRow>
<TableHead className="px-6">Usuário</TableHead> <TableHead className="w-12 px-4">
<Checkbox
checked={isVisibleIndeterminate ? "indeterminate" : allVisibleSelected}
onCheckedChange={(checked) => toggleVisibleSelection(checked === true)}
aria-label="Selecionar todos os usuários visíveis"
/>
</TableHead>
<TableHead className="min-w-[220px] px-4">Usuário</TableHead>
<TableHead className="px-4">Empresa</TableHead> <TableHead className="px-4">Empresa</TableHead>
<TableHead className="px-4">Papel</TableHead> <TableHead className="px-4">Papel</TableHead>
<TableHead className="px-4">Último acesso</TableHead> <TableHead className="px-4">Último acesso</TableHead>
<TableHead className="px-4 text-right">Ações</TableHead> <TableHead className="px-4 text-right">Ações</TableHead>
<TableHead className="px-4 text-right">Selecionar</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -473,7 +500,16 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
.join("") .join("")
return ( return (
<TableRow key={account.id} className="hover:bg-muted/40"> <TableRow key={account.id} className="hover:bg-muted/40">
<TableCell className="px-6 py-3"> <TableCell className="w-12 px-4 py-3 align-middle">
<Checkbox
checked={rowSelection[account.id] ?? false}
onCheckedChange={(checked) =>
setRowSelection((prev) => ({ ...prev, [account.id]: checked === true }))
}
aria-label={`Selecionar ${account.name}`}
/>
</TableCell>
<TableCell className="px-4 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar className="size-9 border border-border/60"> <Avatar className="size-9 border border-border/60">
<AvatarFallback>{initials || account.email.charAt(0).toUpperCase()}</AvatarFallback> <AvatarFallback>{initials || account.email.charAt(0).toUpperCase()}</AvatarFallback>
@ -516,17 +552,6 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
</Button> </Button>
</div> </div>
</TableCell> </TableCell>
<TableCell className="px-4 py-3 text-right align-middle">
<div className="flex justify-end">
<Checkbox
checked={rowSelection[account.id] ?? false}
onCheckedChange={(checked) =>
setRowSelection((prev) => ({ ...prev, [account.id]: Boolean(checked) }))
}
aria-label={`Selecionar ${account.name}`}
/>
</div>
</TableCell>
</TableRow> </TableRow>
) )
}) })

View file

@ -89,7 +89,6 @@ function RequesterPreview({ customer, company }: RequesterPreviewProps) {
} }
const NO_COMPANY_VALUE = "__no_company__" const NO_COMPANY_VALUE = "__no_company__"
const AUTO_COMPANY_VALUE = "__auto__"
const schema = z.object({ const schema = z.object({
subject: z.string().default(""), subject: z.string().default(""),
@ -118,7 +117,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
channel: "MANUAL", channel: "MANUAL",
queueName: null, queueName: null,
assigneeId: null, assigneeId: null,
companyId: AUTO_COMPANY_VALUE, companyId: NO_COMPANY_VALUE,
requesterId: "", requesterId: "",
categoryId: "", categoryId: "",
subcategoryId: "", subcategoryId: "",
@ -180,7 +179,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
const queueValue = form.watch("queueName") ?? "NONE" const queueValue = form.watch("queueName") ?? "NONE"
const assigneeValue = form.watch("assigneeId") ?? null const assigneeValue = form.watch("assigneeId") ?? null
const assigneeSelectValue = assigneeValue ?? "NONE" const assigneeSelectValue = assigneeValue ?? "NONE"
const companyValue = form.watch("companyId") ?? AUTO_COMPANY_VALUE const companyValue = form.watch("companyId") ?? NO_COMPANY_VALUE
const requesterValue = form.watch("requesterId") ?? "" const requesterValue = form.watch("requesterId") ?? ""
const categoryIdValue = form.watch("categoryId") const categoryIdValue = form.watch("categoryId")
const subcategoryIdValue = form.watch("subcategoryId") const subcategoryIdValue = form.watch("subcategoryId")
@ -188,18 +187,25 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
const companyOptions = useMemo(() => { const companyOptions = useMemo(() => {
const map = new Map<string, { id: string; name: string; isAvulso?: boolean; keywords: string[] }>() const map = new Map<string, { id: string; name: string; isAvulso?: boolean; keywords: string[] }>()
companies.forEach((company) => { companies.forEach((company) => {
const trimmedName = company.name.trim()
const slugFallback = company.slug?.trim()
const label =
trimmedName.length > 0 ? trimmedName : slugFallback && slugFallback.length > 0 ? slugFallback : `Empresa ${company.id.slice(0, 8)}`
map.set(company.id, { map.set(company.id, {
id: company.id, id: company.id,
name: company.name.trim().length > 0 ? company.name : "Empresa sem nome", name: label,
isAvulso: false, isAvulso: false,
keywords: company.slug ? [company.slug] : [], keywords: company.slug ? [company.slug] : [],
}) })
}) })
customers.forEach((customer) => { customers.forEach((customer) => {
if (customer.companyId && !map.has(customer.companyId)) { if (customer.companyId && !map.has(customer.companyId)) {
const trimmedName = customer.companyName?.trim() ?? ""
const label =
trimmedName.length > 0 ? trimmedName : `Empresa ${customer.companyId.slice(0, 8)}`
map.set(customer.companyId, { map.set(customer.companyId, {
id: customer.companyId, id: customer.companyId,
name: customer.companyName && customer.companyName.trim().length > 0 ? customer.companyName : "Empresa sem nome", name: label,
isAvulso: customer.companyIsAvulso, isAvulso: customer.companyIsAvulso,
keywords: [], keywords: [],
}) })
@ -208,12 +214,12 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
const base: Array<{ id: string; name: string; isAvulso?: boolean; keywords: string[] }> = [ const base: Array<{ id: string; name: string; isAvulso?: boolean; keywords: string[] }> = [
{ id: NO_COMPANY_VALUE, name: "Sem empresa", keywords: ["sem empresa", "nenhuma"], isAvulso: false }, { id: NO_COMPANY_VALUE, name: "Sem empresa", keywords: ["sem empresa", "nenhuma"], isAvulso: false },
] ]
const sorted = Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, "pt-BR")) const sorted = Array.from(map.values())
.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
return [...base, ...sorted] return [...base, ...sorted]
}, [companies, customers]) }, [companies, customers])
const filteredCustomers = useMemo(() => { const filteredCustomers = useMemo(() => {
if (companyValue === AUTO_COMPANY_VALUE) return customers
if (companyValue === NO_COMPANY_VALUE) { if (companyValue === NO_COMPANY_VALUE) {
return customers.filter((customer) => !customer.companyId) return customers.filter((customer) => !customer.companyId)
} }
@ -236,11 +242,10 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
[companyOptions], [companyOptions],
) )
const selectedCompanyOption = useMemo(() => { const selectedCompanyOption = useMemo(
if (companyValue === AUTO_COMPANY_VALUE) return null () => companyOptionMap.get(companyValue) ?? null,
const key = companyValue === NO_COMPANY_VALUE ? NO_COMPANY_VALUE : companyValue [companyOptionMap, companyValue],
return companyOptionMap.get(key) ?? null )
}, [companyOptionMap, companyValue])
const requesterById = useMemo( const requesterById = useMemo(
() => new Map(customers.map((customer) => [customer.id, customer])), () => new Map(customers.map((customer) => [customer.id, customer])),
@ -275,7 +280,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setCustomersInitialized(false) setCustomersInitialized(false)
form.setValue("companyId", AUTO_COMPANY_VALUE, { shouldDirty: false, shouldTouch: false }) form.setValue("companyId", NO_COMPANY_VALUE, { shouldDirty: false, shouldTouch: false })
form.setValue("requesterId", "", { shouldDirty: false, shouldTouch: false }) form.setValue("requesterId", "", { shouldDirty: false, shouldTouch: false })
return return
} }
@ -294,7 +299,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
if (selected?.companyId) { if (selected?.companyId) {
form.setValue("companyId", selected.companyId, { shouldDirty: false, shouldTouch: false }) form.setValue("companyId", selected.companyId, { shouldDirty: false, shouldTouch: false })
} else { } else {
form.setValue("companyId", selected ? NO_COMPANY_VALUE : AUTO_COMPANY_VALUE, { shouldDirty: false, shouldTouch: false }) form.setValue("companyId", NO_COMPANY_VALUE, { shouldDirty: false, shouldTouch: false })
} }
setCustomersInitialized(true) setCustomersInitialized(true)
}, [open, customersInitialized, customers, convexUserId, form]) }, [open, customersInitialized, customers, convexUserId, form])
@ -558,15 +563,13 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
<Field> <Field>
<FieldLabel>Empresa</FieldLabel> <FieldLabel>Empresa</FieldLabel>
<SearchableCombobox <SearchableCombobox
value={companyValue === AUTO_COMPANY_VALUE ? null : companyValue} value={companyValue}
onValueChange={(nextValue) => { onValueChange={(nextValue) => {
const normalizedValue = nextValue ?? AUTO_COMPANY_VALUE const normalizedValue = nextValue ?? NO_COMPANY_VALUE
const nextCustomers = const nextCustomers =
normalizedValue === AUTO_COMPANY_VALUE normalizedValue === NO_COMPANY_VALUE
? customers ? customers.filter((customer) => !customer.companyId)
: normalizedValue === NO_COMPANY_VALUE : customers.filter((customer) => customer.companyId === normalizedValue)
? customers.filter((customer) => !customer.companyId)
: customers.filter((customer) => customer.companyId === normalizedValue)
form.setValue("companyId", normalizedValue, { form.setValue("companyId", normalizedValue, {
shouldDirty: normalizedValue !== companyValue, shouldDirty: normalizedValue !== companyValue,
shouldTouch: true, shouldTouch: true,
@ -588,8 +591,6 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
}} }}
options={companyComboboxOptions} options={companyComboboxOptions}
placeholder="Selecionar empresa" placeholder="Selecionar empresa"
allowClear
clearLabel="Qualquer empresa"
renderValue={(option) => renderValue={(option) =>
option ? ( option ? (
<span className="truncate">{option.label}</span> <span className="truncate">{option.label}</span>

View file

@ -256,8 +256,8 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
? "relative break-words rounded-xl border border-amber-200/80 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700 shadow-[0_0_0_1px_rgba(217,119,6,0.08)]" ? "relative break-words rounded-xl border border-amber-200/80 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700 shadow-[0_0_0_1px_rgba(217,119,6,0.08)]"
: "relative break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700" : "relative break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700"
const bodyEditButtonClass = isPublic const bodyEditButtonClass = isPublic
? "absolute right-2 top-2 inline-flex size-7 items-center justify-center rounded-full border border-amber-200 bg-white text-amber-700 opacity-0 transition hover:border-amber-500 hover:text-amber-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/30 group-hover/comment:opacity-100" ? "absolute right-3 top-1/2 inline-flex size-7 -translate-y-1/2 items-center justify-center rounded-full border border-amber-200 bg-white text-amber-700 opacity-0 transition hover:border-amber-500 hover:text-amber-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/30 group-hover/comment:opacity-100"
: "absolute right-2 top-2 inline-flex size-7 items-center justify-center rounded-full border border-slate-300 bg-white text-neutral-700 opacity-0 transition hover:border-black hover:text-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/20 group-hover/comment:opacity-100" : "absolute right-3 top-1/2 inline-flex size-7 -translate-y-1/2 items-center justify-center rounded-full border border-slate-300 bg-white text-neutral-700 opacity-0 transition hover:border-black hover:text-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/20 group-hover/comment:opacity-100"
const addContentButtonClass = isPublic const addContentButtonClass = isPublic
? "inline-flex items-center gap-2 text-sm font-medium text-amber-700 transition hover:text-amber-900" ? "inline-flex items-center gap-2 text-sm font-medium text-amber-700 transition hover:text-amber-900"
: "inline-flex items-center gap-2 text-sm font-medium text-neutral-600 transition hover:text-neutral-900" : "inline-flex items-center gap-2 text-sm font-medium text-neutral-600 transition hover:text-neutral-900"

View file

@ -39,6 +39,7 @@ import {
toServerTimestamp, toServerTimestamp,
type SessionStartOrigin, type SessionStartOrigin,
} from "./ticket-timer.utils" } from "./ticket-timer.utils"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
interface TicketHeaderProps { interface TicketHeaderProps {
ticket: TicketWithDetails ticket: TicketWithDetails
@ -109,7 +110,6 @@ type CustomerOption = {
avatarUrl: string | null avatarUrl: string | null
} }
const ALL_COMPANIES_VALUE = "__all__"
const NO_COMPANY_VALUE = "__no_company__" const NO_COMPANY_VALUE = "__no_company__"
const NO_REQUESTER_VALUE = "__no_requester__" const NO_REQUESTER_VALUE = "__no_requester__"
@ -250,7 +250,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const [closeOpen, setCloseOpen] = useState(false) const [closeOpen, setCloseOpen] = useState(false)
const [assigneeChangeReason, setAssigneeChangeReason] = useState("") const [assigneeChangeReason, setAssigneeChangeReason] = useState("")
const [assigneeReasonError, setAssigneeReasonError] = useState<string | null>(null) const [assigneeReasonError, setAssigneeReasonError] = useState<string | null>(null)
const [companySelection, setCompanySelection] = useState<string>(ALL_COMPANIES_VALUE) const [companySelection, setCompanySelection] = useState<string>(NO_COMPANY_VALUE)
const [requesterSelection, setRequesterSelection] = useState<string | null>(ticket.requester.id) const [requesterSelection, setRequesterSelection] = useState<string | null>(ticket.requester.id)
const [requesterError, setRequesterError] = useState<string | null>(null) const [requesterError, setRequesterError] = useState<string | null>(null)
const [customersInitialized, setCustomersInitialized] = useState(false) const [customersInitialized, setCustomersInitialized] = useState(false)
@ -278,32 +278,47 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
if (ticket.company?.id) return String(ticket.company.id) if (ticket.company?.id) return String(ticket.company.id)
return NO_COMPANY_VALUE return NO_COMPANY_VALUE
}, [currentRequesterRecord, ticket.company?.id]) }, [currentRequesterRecord, ticket.company?.id])
const companyOptions = useMemo(() => { const companyMeta = useMemo(() => {
const map = new Map<string, { id: string; name: string; isAvulso?: boolean }>() const map = new Map<string, { name: string; isAvulso?: boolean; keywords: string[] }>()
companies.forEach((company) => { companies.forEach((company) => {
map.set(company.id, { id: company.id, name: company.name, isAvulso: false }) const trimmedName = company.name.trim()
const slugFallback = company.slug?.trim()
const label =
trimmedName.length > 0
? trimmedName
: slugFallback && slugFallback.length > 0
? slugFallback
: `Empresa ${company.id.slice(0, 8)}`
const keywords = slugFallback ? [slugFallback] : []
map.set(company.id, { name: label, isAvulso: false, keywords })
}) })
customers.forEach((customer) => { customers.forEach((customer) => {
if (customer.companyId && !map.has(customer.companyId)) { if (customer.companyId && !map.has(customer.companyId)) {
const trimmedName = customer.companyName?.trim() ?? ""
const label =
trimmedName.length > 0 ? trimmedName : `Empresa ${customer.companyId.slice(0, 8)}`
map.set(customer.companyId, { map.set(customer.companyId, {
id: customer.companyId, name: label,
name: customer.companyName ?? "Empresa sem nome",
isAvulso: customer.companyIsAvulso, isAvulso: customer.companyIsAvulso,
keywords: [],
}) })
} }
}) })
const includeNoCompany = customers.some((customer) => !customer.companyId) return map
const result: Array<{ id: string; name: string; isAvulso?: boolean }> = [
{ id: ALL_COMPANIES_VALUE, name: "Todas as empresas" },
]
if (includeNoCompany) {
result.push({ id: NO_COMPANY_VALUE, name: "Sem empresa" })
}
const sorted = Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
return [...result, ...sorted]
}, [companies, customers]) }, [companies, customers])
const companyComboboxOptions = useMemo<SearchableComboboxOption[]>(() => {
const entries = Array.from(companyMeta.entries())
.map(([id, meta]) => ({
value: id,
label: meta.name,
keywords: meta.keywords,
}))
.sort((a, b) => a.label.localeCompare(b.label, "pt-BR"))
return [{ value: NO_COMPANY_VALUE, label: "Sem empresa", keywords: ["sem empresa", "nenhuma"] }, ...entries]
}, [companyMeta])
const filteredCustomers = useMemo(() => { const filteredCustomers = useMemo(() => {
if (companySelection === ALL_COMPANIES_VALUE) return customers
if (companySelection === NO_COMPANY_VALUE) { if (companySelection === NO_COMPANY_VALUE) {
return customers.filter((customer) => !customer.companyId) return customers.filter((customer) => !customer.companyId)
} }
@ -312,6 +327,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const assigneeDirty = useMemo(() => assigneeSelection !== currentAssigneeId, [assigneeSelection, currentAssigneeId]) const assigneeDirty = useMemo(() => assigneeSelection !== currentAssigneeId, [assigneeSelection, currentAssigneeId])
const requesterDirty = useMemo(() => requesterSelection !== ticket.requester.id, [requesterSelection, ticket.requester.id]) const requesterDirty = useMemo(() => requesterSelection !== ticket.requester.id, [requesterSelection, ticket.requester.id])
const formDirty = dirty || categoryDirty || queueDirty || assigneeDirty || requesterDirty const formDirty = dirty || categoryDirty || queueDirty || assigneeDirty || requesterDirty
const assigneeReasonRequired = assigneeDirty && !isManager
const assigneeReasonValid = !assigneeReasonRequired || assigneeChangeReason.trim().length >= 5
const saveDisabled = !formDirty || saving || !assigneeReasonValid
const companyLabel = useMemo(() => { const companyLabel = useMemo(() => {
if (ticket.company?.name) return ticket.company.name if (ticket.company?.name) return ticket.company.name
if (isAvulso) return "Cliente avulso" if (isAvulso) return "Cliente avulso"
@ -1238,23 +1256,35 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Empresa</span> <span className={sectionLabelClass}>Empresa</span>
{editing && !isManager ? ( {editing && !isManager ? (
<Select <SearchableCombobox
value={companySelection} value={companySelection}
onValueChange={(value) => { onValueChange={(value) => {
setCompanySelection(value) setCompanySelection(value ?? NO_COMPANY_VALUE)
}} }}
> options={companyComboboxOptions}
<SelectTrigger className={selectTriggerClass}> placeholder="Selecionar empresa"
<SelectValue placeholder="Selecionar" /> disabled={isManager}
</SelectTrigger> renderValue={(option) =>
<SelectContent className="max-h-64 rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm"> option ? (
{companyOptions.map((option) => ( <span className="truncate">{option.label}</span>
<SelectItem key={option.id} value={option.id}> ) : (
{option.name} <span className="text-muted-foreground">Selecionar empresa</span>
</SelectItem> )
))} }
</SelectContent> renderOption={(option) => {
</Select> const meta = companyMeta.get(option.value)
return (
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-foreground">{option.label}</span>
{meta?.isAvulso ? (
<Badge variant="outline" className="rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide">
Avulsa
</Badge>
) : null}
</div>
)
}}
/>
) : ( ) : (
<span className={sectionValueClass}>{companyLabel}</span> <span className={sectionValueClass}>{companyLabel}</span>
)} )}
@ -1350,6 +1380,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</p> </p>
{assigneeReasonError ? ( {assigneeReasonError ? (
<p className="text-xs font-semibold text-rose-600">{assigneeReasonError}</p> <p className="text-xs font-semibold text-rose-600">{assigneeReasonError}</p>
) : assigneeReasonRequired && assigneeChangeReason.trim().length < 5 ? (
<p className="text-xs font-semibold text-rose-600">Descreva o motivo com pelo menos 5 caracteres.</p>
) : null} ) : null}
</div> </div>
) : null} ) : null}
@ -1381,7 +1413,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<Button variant="ghost" size="sm" className="text-sm font-semibold text-neutral-700" onClick={handleCancel}> <Button variant="ghost" size="sm" className="text-sm font-semibold text-neutral-700" onClick={handleCancel}>
Cancelar Cancelar
</Button> </Button>
<Button size="sm" className={startButtonClass} onClick={handleSave} disabled={!formDirty || saving}> <Button size="sm" className={startButtonClass} onClick={handleSave} disabled={saveDisabled}>
Salvar Salvar
</Button> </Button>
</div> </div>

View file

@ -0,0 +1,156 @@
"use client"
import Link from "next/link"
import { formatDistanceToNowStrict } from "date-fns"
import { ptBR } from "date-fns/locale"
import { LayoutGrid } from "lucide-react"
import type { Ticket, TicketPriority, TicketStatus } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"
import { cn } from "@/lib/utils"
type TicketsBoardProps = {
tickets: Ticket[]
}
const statusLabel: Record<TicketStatus, string> = {
PENDING: "Pendente",
AWAITING_ATTENDANCE: "Em andamento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
}
const statusChipClass: Record<TicketStatus, string> = {
PENDING: "bg-amber-100 text-amber-800 ring-1 ring-amber-200",
AWAITING_ATTENDANCE: "bg-sky-100 text-sky-700 ring-1 ring-sky-200",
PAUSED: "bg-violet-100 text-violet-700 ring-1 ring-violet-200",
RESOLVED: "bg-emerald-100 text-emerald-700 ring-1 ring-emerald-200",
}
const priorityLabel: Record<TicketPriority, string> = {
LOW: "Baixa",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Urgente",
}
const priorityChipClass: Record<TicketPriority, string> = {
LOW: "bg-slate-100 text-slate-700",
MEDIUM: "bg-sky-100 text-sky-700",
HIGH: "bg-amber-100 text-amber-800",
URGENT: "bg-rose-100 text-rose-700",
}
function formatUpdated(date: Date) {
return formatDistanceToNowStrict(date, { addSuffix: true, locale: ptBR })
}
export function TicketsBoard({ tickets }: TicketsBoardProps) {
if (!tickets.length) {
return (
<div className="rounded-3xl border border-slate-200 bg-white p-12 text-center shadow-sm">
<Empty>
<EmptyHeader>
<LayoutGrid className="mx-auto size-9 text-neutral-400" />
</EmptyHeader>
<EmptyContent>
<EmptyTitle>Nenhum ticket encontrado</EmptyTitle>
<EmptyDescription>
Ajuste os filtros ou crie um novo ticket para visualizar aqui na visão em quadro.
</EmptyDescription>
</EmptyContent>
</Empty>
</div>
)
}
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{tickets.map((ticket) => (
<Link
key={ticket.id}
href={`/tickets/${ticket.id}`}
className="group block h-full rounded-3xl border border-slate-200 bg-white p-4 shadow-sm transition hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-300"
>
<div className="flex items-start justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<Badge
variant="outline"
className="border-slate-200 bg-slate-100 px-2.5 py-1 text-[11px] font-semibold text-neutral-700"
>
#{ticket.reference}
</Badge>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-semibold transition",
statusChipClass[ticket.status],
)}
>
{statusLabel[ticket.status]}
</span>
</div>
<span className="text-[11px] font-semibold uppercase tracking-wide text-neutral-400">
{formatUpdated(ticket.updatedAt)}
</span>
</div>
<h3 className="mt-3 line-clamp-2 text-sm font-semibold text-neutral-900">
{ticket.subject || "Sem assunto"}
</h3>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-neutral-600">
<span className="font-medium text-neutral-500">Fila:</span>
<span className="rounded-full bg-slate-100 px-2.5 py-0.5 text-[11px] font-medium text-neutral-700">
{ticket.queue ?? "Sem fila"}
</span>
<span className="font-medium text-neutral-500">Prioridade:</span>
<span
className={cn(
"rounded-full px-2.5 py-0.5 text-[11px] font-semibold shadow-sm",
priorityChipClass[ticket.priority],
)}
>
{priorityLabel[ticket.priority]}
</span>
</div>
<dl className="mt-4 space-y-2 text-xs text-neutral-600">
<div className="flex items-center justify-between gap-3">
<dt className="font-medium text-neutral-500">Empresa</dt>
<dd className="truncate text-right text-neutral-700">
{ticket.company?.name ?? "Sem empresa"}
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="font-medium text-neutral-500">Responsável</dt>
<dd className="truncate text-right text-neutral-700">
{ticket.assignee?.name ?? "Sem responsável"}
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="font-medium text-neutral-500">Solicitante</dt>
<dd className="truncate text-right text-neutral-700">
{ticket.requester?.name ?? ticket.requester?.email ?? "—"}
</dd>
</div>
</dl>
{ticket.tags.length > 0 ? (
<div className="mt-4 flex flex-wrap items-center gap-2">
{ticket.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-neutral-600"
>
{tag}
</span>
))}
{ticket.tags.length > 3 ? (
<span className="text-[11px] font-semibold text-neutral-400">
+{ticket.tags.length - 3}
</span>
) : null}
</div>
) : null}
</Link>
))}
</div>
)
}

View file

@ -10,8 +10,11 @@ import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
import type { Ticket, TicketQueueSummary } from "@/lib/schemas/ticket" import type { Ticket, TicketQueueSummary } from "@/lib/schemas/ticket"
import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters" import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters"
import { TicketsTable } from "@/components/tickets/tickets-table" import { TicketsTable } from "@/components/tickets/tickets-table"
import { TicketsBoard } from "@/components/tickets/tickets-board"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"
import { useDefaultQueues } from "@/hooks/use-default-queues" import { useDefaultQueues } from "@/hooks/use-default-queues"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { LayoutGrid, List } from "lucide-react"
type TicketsViewProps = { type TicketsViewProps = {
initialFilters?: Partial<TicketFiltersState> initialFilters?: Partial<TicketFiltersState>
@ -26,10 +29,31 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
[initialFilters] [initialFilters]
) )
const [filters, setFilters] = useState<TicketFiltersState>(mergedInitialFilters) const [filters, setFilters] = useState<TicketFiltersState>(mergedInitialFilters)
const [viewMode, setViewMode] = useState<"table" | "board">("table")
useEffect(() => { useEffect(() => {
setFilters(mergedInitialFilters) setFilters(mergedInitialFilters)
}, [mergedInitialFilters]) }, [mergedInitialFilters])
useEffect(() => {
try {
const stored = localStorage.getItem("tickets:view-mode")
if (stored === "table" || stored === "board") {
setViewMode(stored)
}
} catch {
// ignore
}
}, [])
useEffect(() => {
try {
localStorage.setItem("tickets:view-mode", viewMode)
} catch {
// ignore
}
}, [viewMode])
const { session, convexUserId, isStaff } = useAuth() const { session, convexUserId, isStaff } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
@ -145,21 +169,42 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
assignees={(agents ?? []).map((a) => ({ id: a._id, name: a.name }))} assignees={(agents ?? []).map((a) => ({ id: a._id, name: a.name }))}
initialState={mergedInitialFilters} initialState={mergedInitialFilters}
/> />
<div className="flex items-center justify-end gap-2"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<button <ToggleGroup
type="button" type="single"
onClick={handleSaveDefault} value={viewMode}
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-neutral-800 hover:bg-slate-50" onValueChange={(next) => {
if (!next) return
setViewMode(next as "table" | "board")
}}
variant="outline"
className="inline-flex rounded-md border border-border/60 bg-muted/30"
> >
Salvar filtro como padrão <ToggleGroupItem value="table" aria-label="Listagem em tabela" className="min-w-[96px] justify-center gap-2">
</button> <List className="size-4" />
<button <span>Tabela</span>
type="button" </ToggleGroupItem>
onClick={handleClearDefault} <ToggleGroupItem value="board" aria-label="Visão em quadro" className="min-w-[96px] justify-center gap-2">
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-neutral-600 hover:bg-slate-50" <LayoutGrid className="size-4" />
> <span>Quadro</span>
Limpar padrão </ToggleGroupItem>
</button> </ToggleGroup>
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={handleSaveDefault}
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-neutral-800 hover:bg-slate-50"
>
Salvar filtro como padrão
</button>
<button
type="button"
onClick={handleClearDefault}
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-neutral-600 hover:bg-slate-50"
>
Limpar padrão
</button>
</div>
</div> </div>
{ticketsRaw === undefined ? ( {ticketsRaw === undefined ? (
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"> <div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
@ -172,6 +217,8 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
))} ))}
</div> </div>
</div> </div>
) : viewMode === "board" ? (
<TicketsBoard tickets={filteredTickets} />
) : ( ) : (
<TicketsTable tickets={filteredTickets} /> <TicketsTable tickets={filteredTickets} />
)} )}

View file

@ -10,6 +10,7 @@ import {
} from "react" } from "react"
import type { ReactNode } from "react" import type { ReactNode } from "react"
import { useEditor, EditorContent } from "@tiptap/react" import { useEditor, EditorContent } from "@tiptap/react"
import type { Editor } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit" import StarterKit from "@tiptap/starter-kit"
import Placeholder from "@tiptap/extension-placeholder" import Placeholder from "@tiptap/extension-placeholder"
import Mention from "@tiptap/extension-mention" import Mention from "@tiptap/extension-mention"
@ -253,6 +254,10 @@ const TicketMentionListComponent = (props: TicketMentionSuggestionProps) => (
const TicketMentionExtension = Mention.extend({ const TicketMentionExtension = Mention.extend({
name: "ticketMention", name: "ticketMention",
group: "inline",
inline: true,
draggable: false,
selectable: true,
addAttributes() { addAttributes() {
return { return {
id: { default: null }, id: { default: null },
@ -263,6 +268,48 @@ const TicketMentionExtension = Mention.extend({
url: { default: null }, url: { default: null },
} }
}, },
addKeyboardShortcuts() {
const mentionPrototype = Mention as unknown as {
prototype: { addKeyboardShortcuts?: (this: unknown) => unknown }
}
const parentShortcuts = mentionPrototype.prototype.addKeyboardShortcuts?.call(this) as
| Record<string, (args: { editor: Editor }) => boolean>
| undefined
const parent = parentShortcuts ?? {}
return {
...parent,
Backspace: ({ editor }: { editor: Editor }) => {
const { state } = editor
const { selection } = state
if (selection.empty) {
const { $from } = selection
const nodeBefore = $from.nodeBefore
if (nodeBefore?.type?.name === this.name) {
const from = $from.pos - nodeBefore.nodeSize
const to = $from.pos
editor.chain().focus().deleteRange({ from, to }).run()
return true
}
}
return parent.Backspace ? parent.Backspace({ editor }) : false
},
Delete: ({ editor }: { editor: Editor }) => {
const { state } = editor
const { selection } = state
if (selection.empty) {
const { $from } = selection
const nodeAfter = $from.nodeAfter
if (nodeAfter?.type?.name === this.name) {
const from = $from.pos
const to = $from.pos + nodeAfter.nodeSize
editor.chain().focus().deleteRange({ from, to }).run()
return true
}
}
return parent.Delete ? parent.Delete({ editor }) : false
},
}
},
parseHTML() { parseHTML() {
return [ return [
{ {