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,
PaginationPrevious,
} 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useQuery } from "convex/react"
@ -35,6 +43,7 @@ import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
import { canReactivateInvite as canReactivateInvitePolicy } from "@/lib/invite-policies"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
type AdminRole = RoleOption | "machine"
const NO_COMPANY_ID = "__none__"
@ -284,6 +293,31 @@ export function AdminUsersManager({
)
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"
// Equipe
const [teamSearch, setTeamSearch] = useState("")
@ -1158,28 +1192,28 @@ async function handleDeleteUser() {
/>
</div>
<div className="mt-3 flex flex-wrap items-center gap-3 md:mt-0">
<Select value={teamRoleFilter} onValueChange={(value) => setTeamRoleFilter(value as "all" | RoleOption)}>
<SelectTrigger className="h-9 w-full sm:w-48">
<SelectValue placeholder="Todos os papéis" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os papéis</SelectItem>
{selectableRoles.filter((option) => ["admin", "agent"].includes(option)).map((option) => (
<SelectItem key={`team-filter-${option}`} value={option}>{formatRole(option)}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={teamCompanyFilter} onValueChange={setTeamCompanyFilter}>
<SelectTrigger className="h-9 w-full sm:w-56">
<SelectValue placeholder="Empresa" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as empresas</SelectItem>
{companies.map((company) => (
<SelectItem key={`team-company-${company.id}`} value={company.id}>{company.name}</SelectItem>
))}
</SelectContent>
</Select>
<SearchableCombobox
value={teamRoleFilter}
onValueChange={(value) => {
if (value === "admin" || value === "agent") {
setTeamRoleFilter(value)
return
}
setTeamRoleFilter("all")
}}
options={teamRoleFilterOptions}
placeholder="Todos os papéis"
searchPlaceholder="Buscar papel..."
className="md:w-48"
/>
<SearchableCombobox
value={teamCompanyFilter}
onValueChange={(value) => setTeamCompanyFilter(value ?? "all")}
options={companyFilterOptions}
placeholder="Todas as empresas"
searchPlaceholder="Buscar empresa..."
className="md:w-56"
/>
{/* Filtro por espaço removido */}
{(teamSearch.trim().length > 0 || teamRoleFilter !== "all") ? (
<Button
@ -1214,32 +1248,30 @@ async function handleDeleteUser() {
<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">
<Table className="min-w-[960px] table-fixed text-sm">
<TableHeader className="bg-slate-100/80">
<TableRow className="text-xs uppercase tracking-wide text-neutral-500">
<TableHead className="w-12 px-4">
<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">
</TableHead>
<TableHead className="px-4">Nome</TableHead>
<TableHead className="px-4">E-mail</TableHead>
<TableHead className="px-4">Papel</TableHead>
<TableHead className="px-4">Empresa</TableHead>
<TableHead className="px-4">Máquinas</TableHead>
<TableHead className="px-4">Criado em</TableHead>
<TableHead className="px-4 text-right">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody className="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">
<TableRow key={user.id} className="hover:bg-slate-100/70">
<TableCell className="w-12 px-4">
<div className="flex items-center justify-center">
<Checkbox
checked={teamSelection.has(user.id)}
@ -1254,12 +1286,12 @@ async function handleDeleteUser() {
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">
</TableCell>
<TableCell className="px-4 font-medium text-neutral-800">{user.name || "—"}</TableCell>
<TableCell className="px-4 text-neutral-600">{user.email}</TableCell>
<TableCell className="px-4 text-neutral-600">{formatRole(user.role)}</TableCell>
<TableCell className="px-4 text-neutral-600">{user.companyName ?? "—"}</TableCell>
<TableCell className="px-4 text-neutral-600">
{(() => {
const list = machinesByUserEmail.get((user.email ?? "").toLowerCase()) ?? []
if (list.length === 0) return "—"
@ -1273,10 +1305,10 @@ async function handleDeleteUser() {
</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">
</TableCell>
<TableCell className="px-4 text-neutral-500">{formatDate(user.createdAt)}</TableCell>
<TableCell className="px-4">
<div className="flex flex-wrap justify-end gap-2">
<Button
variant="outline"
size="sm"
@ -1301,20 +1333,20 @@ async function handleDeleteUser() {
Remover
</Button>
</div>
</td>
</tr>
</TableCell>
</TableRow>
))
) : (
<tr>
<td colSpan={8} className="px-6 py-6 text-center text-sm text-neutral-500">
<TableRow>
<TableCell 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>
</TableCell>
</TableRow>
)}
</tbody>
</table>
</TableBody>
</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">
@ -1443,27 +1475,28 @@ async function handleDeleteUser() {
/>
</div>
<div className="mt-3 flex flex-wrap items-center gap-3 md:mt-0">
<Select value={usersTypeFilter} onValueChange={(v) => setUsersTypeFilter(v as typeof usersTypeFilter)}>
<SelectTrigger className="h-9 w-full sm:w-40">
<SelectValue placeholder="Tipo" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="people">Pessoas</SelectItem>
<SelectItem value="machines">Máquinas</SelectItem>
</SelectContent>
</Select>
<Select value={usersCompanyFilter} onValueChange={setUsersCompanyFilter}>
<SelectTrigger className="h-9 w-full sm:w-56">
<SelectValue placeholder="Empresa" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as empresas</SelectItem>
{companies.map((company) => (
<SelectItem key={`users-company-${company.id}`} value={company.id}>{company.name}</SelectItem>
))}
</SelectContent>
</Select>
<SearchableCombobox
value={usersTypeFilter}
onValueChange={(value) => {
if (value === "people" || value === "machines") {
setUsersTypeFilter(value)
return
}
setUsersTypeFilter("all")
}}
options={usersTypeFilterOptions}
placeholder="Todos"
searchPlaceholder="Buscar tipo..."
className="md:w-40"
/>
<SearchableCombobox
value={usersCompanyFilter}
onValueChange={(value) => setUsersCompanyFilter(value ?? "all")}
options={companyFilterOptions}
placeholder="Todas as empresas"
searchPlaceholder="Buscar empresa..."
className="md:w-56"
/>
{/* Filtro por espaço removido */}
{(usersSearch.trim().length > 0 || usersTypeFilter !== "all" || usersCompanyFilter !== "all") ? (
<Button
@ -1498,15 +1531,13 @@ async function handleDeleteUser() {
<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">
<Table className="min-w-[960px] table-fixed text-sm">
<TableHeader className="bg-slate-100/80">
<TableRow className="text-xs uppercase tracking-wide text-neutral-500">
<TableHead className="w-12 px-4">
<Checkbox
checked={
usersSelection.size > 0 &&
usersSelection.size === filteredCombinedUsers.length
usersSelection.size > 0 && usersSelection.size === filteredCombinedUsers.length
? true
: usersSelection.size > 0
? "indeterminate"
@ -1515,22 +1546,21 @@ async function handleDeleteUser() {
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">
</TableHead>
<TableHead className="px-4">Nome</TableHead>
<TableHead className="px-4">E-mail</TableHead>
<TableHead className="px-4">Tipo</TableHead>
<TableHead className="px-4">Perfil</TableHead>
<TableHead className="px-4">Empresa</TableHead>
<TableHead className="px-4">Criado em</TableHead>
<TableHead className="px-4 text-right">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody className="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">
<TableRow key={user.id} className="hover:bg-slate-100/70">
<TableCell className="w-12 px-4">
<div className="flex items-center justify-center">
<Checkbox
checked={usersSelection.has(user.id)}
@ -1545,15 +1575,15 @@ async function handleDeleteUser() {
aria-label="Selecionar linha"
/>
</div>
</td>
<td className="px-4 py-3 font-medium text-neutral-800 first:pl-6">
</TableCell>
<TableCell className="px-4 font-medium text-neutral-800">
{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">
</TableCell>
<TableCell className="px-4 text-neutral-600">{user.email}</TableCell>
<TableCell className="px-4 text-neutral-600">
{user.role === "machine" ? "Máquina" : "Pessoa"}
</td>
<td className="px-4 py-3 text-neutral-600">
</TableCell>
<TableCell className="px-4 text-neutral-600">
{user.role === "machine" ? (
user.machinePersona ? (
<Badge
@ -1568,11 +1598,11 @@ async function handleDeleteUser() {
) : (
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">
</TableCell>
<TableCell className="px-4 text-neutral-600">{user.companyName ?? "—"}</TableCell>
<TableCell className="px-4 text-neutral-500">{formatDate(user.createdAt)}</TableCell>
<TableCell className="px-4">
<div className="flex flex-wrap justify-end gap-2">
<Button
variant="outline"
size="sm"
@ -1610,20 +1640,20 @@ async function handleDeleteUser() {
Remover
</Button>
</div>
</td>
</tr>
</TableCell>
</TableRow>
))
) : (
<tr>
<td colSpan={8} className="px-6 py-6 text-center text-sm text-neutral-500">
<TableRow>
<TableCell 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>
</TableCell>
</TableRow>
)}
</tbody>
</table>
</TableBody>
</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">
@ -1777,30 +1807,28 @@ async function handleDeleteUser() {
<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">
<Table className="min-w-[800px] table-fixed text-sm">
<TableHeader className="bg-slate-100/80">
<TableRow className="text-xs uppercase tracking-wide text-neutral-500">
<TableHead className="w-12 px-4">
<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">
</TableHead>
<TableHead className="px-4">Colaborador</TableHead>
<TableHead className="px-4">Papel</TableHead>
<TableHead className="px-4">Expira em</TableHead>
<TableHead className="px-4">Status</TableHead>
<TableHead className="px-4 text-right">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody className="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">
<TableRow key={invite.id} className="hover:bg-slate-100/70">
<TableCell className="w-12 px-4">
<div className="flex items-center justify-center">
<Checkbox
checked={inviteSelection.has(invite.id)}
@ -1815,25 +1843,25 @@ async function handleDeleteUser() {
aria-label="Selecionar linha"
/>
</div>
</td>
<td className="px-4 py-3 first:pl-6">
</TableCell>
<TableCell className="px-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="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">
</TableCell>
<TableCell className="px-4 text-neutral-600">{formatRole(invite.role)}</TableCell>
<TableCell className="px-4 text-neutral-600">{formatDate(invite.expiresAt)}</TableCell>
<TableCell className="px-4">
<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">
</TableCell>
<TableCell className="px-4">
<div className="flex flex-wrap justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => handleCopy(invite.inviteUrl)}>
Copiar link
</Button>
@ -1860,18 +1888,18 @@ async function handleDeleteUser() {
</Button>
) : null}
</div>
</td>
</tr>
</TableCell>
</TableRow>
))
) : (
<tr>
<td colSpan={6} className="px-6 py-6 text-center text-sm text-neutral-500">
<TableRow>
<TableCell colSpan={6} className="px-6 py-6 text-center text-sm text-neutral-500">
Nenhum convite emitido até o momento.
</td>
</tr>
</TableCell>
</TableRow>
)}
</tbody>
</table>
</TableBody>
</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">

View file

@ -182,6 +182,14 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
[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(
() => (editAccountId ? accounts.find((account) => account.id === editAccountId) ?? null : null),
[accounts, editAccountId]
@ -229,6 +237,19 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
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(() => {
setEditAccountId(null)
setEditAuthUserId(null)
@ -448,12 +469,18 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
<Table className="w-full table-fixed text-sm">
<TableHeader className="bg-muted">
<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">Papel</TableHead>
<TableHead className="px-4">Último acesso</TableHead>
<TableHead className="px-4 text-right">Ações</TableHead>
<TableHead className="px-4 text-right">Selecionar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -473,7 +500,16 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
.join("")
return (
<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">
<Avatar className="size-9 border border-border/60">
<AvatarFallback>{initials || account.email.charAt(0).toUpperCase()}</AvatarFallback>
@ -516,17 +552,6 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
</Button>
</div>
</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>
)
})

View file

@ -89,7 +89,6 @@ function RequesterPreview({ customer, company }: RequesterPreviewProps) {
}
const NO_COMPANY_VALUE = "__no_company__"
const AUTO_COMPANY_VALUE = "__auto__"
const schema = z.object({
subject: z.string().default(""),
@ -118,7 +117,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
channel: "MANUAL",
queueName: null,
assigneeId: null,
companyId: AUTO_COMPANY_VALUE,
companyId: NO_COMPANY_VALUE,
requesterId: "",
categoryId: "",
subcategoryId: "",
@ -180,7 +179,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
const queueValue = form.watch("queueName") ?? "NONE"
const assigneeValue = form.watch("assigneeId") ?? null
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 categoryIdValue = form.watch("categoryId")
const subcategoryIdValue = form.watch("subcategoryId")
@ -188,18 +187,25 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
const companyOptions = useMemo(() => {
const map = new Map<string, { id: string; name: string; isAvulso?: boolean; keywords: string[] }>()
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, {
id: company.id,
name: company.name.trim().length > 0 ? company.name : "Empresa sem nome",
name: label,
isAvulso: false,
keywords: company.slug ? [company.slug] : [],
})
})
customers.forEach((customer) => {
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, {
id: customer.companyId,
name: customer.companyName && customer.companyName.trim().length > 0 ? customer.companyName : "Empresa sem nome",
name: label,
isAvulso: customer.companyIsAvulso,
keywords: [],
})
@ -208,12 +214,12 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
const base: Array<{ id: string; name: string; isAvulso?: boolean; keywords: string[] }> = [
{ 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]
}, [companies, customers])
const filteredCustomers = useMemo(() => {
if (companyValue === AUTO_COMPANY_VALUE) return customers
if (companyValue === NO_COMPANY_VALUE) {
return customers.filter((customer) => !customer.companyId)
}
@ -236,11 +242,10 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
[companyOptions],
)
const selectedCompanyOption = useMemo(() => {
if (companyValue === AUTO_COMPANY_VALUE) return null
const key = companyValue === NO_COMPANY_VALUE ? NO_COMPANY_VALUE : companyValue
return companyOptionMap.get(key) ?? null
}, [companyOptionMap, companyValue])
const selectedCompanyOption = useMemo(
() => companyOptionMap.get(companyValue) ?? null,
[companyOptionMap, companyValue],
)
const requesterById = useMemo(
() => new Map(customers.map((customer) => [customer.id, customer])),
@ -275,7 +280,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
useEffect(() => {
if (!open) {
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 })
return
}
@ -294,7 +299,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
if (selected?.companyId) {
form.setValue("companyId", selected.companyId, { shouldDirty: false, shouldTouch: false })
} 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)
}, [open, customersInitialized, customers, convexUserId, form])
@ -558,13 +563,11 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
<Field>
<FieldLabel>Empresa</FieldLabel>
<SearchableCombobox
value={companyValue === AUTO_COMPANY_VALUE ? null : companyValue}
value={companyValue}
onValueChange={(nextValue) => {
const normalizedValue = nextValue ?? AUTO_COMPANY_VALUE
const normalizedValue = nextValue ?? NO_COMPANY_VALUE
const nextCustomers =
normalizedValue === AUTO_COMPANY_VALUE
? customers
: normalizedValue === NO_COMPANY_VALUE
normalizedValue === NO_COMPANY_VALUE
? customers.filter((customer) => !customer.companyId)
: customers.filter((customer) => customer.companyId === normalizedValue)
form.setValue("companyId", normalizedValue, {
@ -588,8 +591,6 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
}}
options={companyComboboxOptions}
placeholder="Selecionar empresa"
allowClear
clearLabel="Qualquer empresa"
renderValue={(option) =>
option ? (
<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-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700"
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-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-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-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
? "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"

View file

@ -39,6 +39,7 @@ import {
toServerTimestamp,
type SessionStartOrigin,
} from "./ticket-timer.utils"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
interface TicketHeaderProps {
ticket: TicketWithDetails
@ -109,7 +110,6 @@ type CustomerOption = {
avatarUrl: string | null
}
const ALL_COMPANIES_VALUE = "__all__"
const NO_COMPANY_VALUE = "__no_company__"
const NO_REQUESTER_VALUE = "__no_requester__"
@ -250,7 +250,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const [closeOpen, setCloseOpen] = useState(false)
const [assigneeChangeReason, setAssigneeChangeReason] = useState("")
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 [requesterError, setRequesterError] = useState<string | null>(null)
const [customersInitialized, setCustomersInitialized] = useState(false)
@ -278,32 +278,47 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
if (ticket.company?.id) return String(ticket.company.id)
return NO_COMPANY_VALUE
}, [currentRequesterRecord, ticket.company?.id])
const companyOptions = useMemo(() => {
const map = new Map<string, { id: string; name: string; isAvulso?: boolean }>()
const companyMeta = useMemo(() => {
const map = new Map<string, { name: string; isAvulso?: boolean; keywords: string[] }>()
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) => {
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, {
id: customer.companyId,
name: customer.companyName ?? "Empresa sem nome",
name: label,
isAvulso: customer.companyIsAvulso,
keywords: [],
})
}
})
const includeNoCompany = customers.some((customer) => !customer.companyId)
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]
return map
}, [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(() => {
if (companySelection === ALL_COMPANIES_VALUE) return customers
if (companySelection === NO_COMPANY_VALUE) {
return customers.filter((customer) => !customer.companyId)
}
@ -312,6 +327,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const assigneeDirty = useMemo(() => assigneeSelection !== currentAssigneeId, [assigneeSelection, currentAssigneeId])
const requesterDirty = useMemo(() => requesterSelection !== ticket.requester.id, [requesterSelection, ticket.requester.id])
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(() => {
if (ticket.company?.name) return ticket.company.name
if (isAvulso) return "Cliente avulso"
@ -1238,23 +1256,35 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Empresa</span>
{editing && !isManager ? (
<Select
<SearchableCombobox
value={companySelection}
onValueChange={(value) => {
setCompanySelection(value)
setCompanySelection(value ?? NO_COMPANY_VALUE)
}}
>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Selecionar" />
</SelectTrigger>
<SelectContent className="max-h-64 rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{companyOptions.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
options={companyComboboxOptions}
placeholder="Selecionar empresa"
disabled={isManager}
renderValue={(option) =>
option ? (
<span className="truncate">{option.label}</span>
) : (
<span className="text-muted-foreground">Selecionar empresa</span>
)
}
renderOption={(option) => {
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>
)}
@ -1350,6 +1380,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</p>
{assigneeReasonError ? (
<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}
</div>
) : 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}>
Cancelar
</Button>
<Button size="sm" className={startButtonClass} onClick={handleSave} disabled={!formDirty || saving}>
<Button size="sm" className={startButtonClass} onClick={handleSave} disabled={saveDisabled}>
Salvar
</Button>
</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 { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters"
import { TicketsTable } from "@/components/tickets/tickets-table"
import { TicketsBoard } from "@/components/tickets/tickets-board"
import { useAuth } from "@/lib/auth-client"
import { useDefaultQueues } from "@/hooks/use-default-queues"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { LayoutGrid, List } from "lucide-react"
type TicketsViewProps = {
initialFilters?: Partial<TicketFiltersState>
@ -26,10 +29,31 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
[initialFilters]
)
const [filters, setFilters] = useState<TicketFiltersState>(mergedInitialFilters)
const [viewMode, setViewMode] = useState<"table" | "board">("table")
useEffect(() => {
setFilters(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 tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
@ -145,6 +169,26 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
assignees={(agents ?? []).map((a) => ({ id: a._id, name: a.name }))}
initialState={mergedInitialFilters}
/>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<ToggleGroup
type="single"
value={viewMode}
onValueChange={(next) => {
if (!next) return
setViewMode(next as "table" | "board")
}}
variant="outline"
className="inline-flex rounded-md border border-border/60 bg-muted/30"
>
<ToggleGroupItem value="table" aria-label="Listagem em tabela" className="min-w-[96px] justify-center gap-2">
<List className="size-4" />
<span>Tabela</span>
</ToggleGroupItem>
<ToggleGroupItem value="board" aria-label="Visão em quadro" className="min-w-[96px] justify-center gap-2">
<LayoutGrid className="size-4" />
<span>Quadro</span>
</ToggleGroupItem>
</ToggleGroup>
<div className="flex items-center justify-end gap-2">
<button
type="button"
@ -161,6 +205,7 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
Limpar padrão
</button>
</div>
</div>
{ticketsRaw === undefined ? (
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="grid gap-3">
@ -172,6 +217,8 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
))}
</div>
</div>
) : viewMode === "board" ? (
<TicketsBoard tickets={filteredTickets} />
) : (
<TicketsTable tickets={filteredTickets} />
)}

View file

@ -10,6 +10,7 @@ import {
} from "react"
import type { ReactNode } from "react"
import { useEditor, EditorContent } from "@tiptap/react"
import type { Editor } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import Placeholder from "@tiptap/extension-placeholder"
import Mention from "@tiptap/extension-mention"
@ -253,6 +254,10 @@ const TicketMentionListComponent = (props: TicketMentionSuggestionProps) => (
const TicketMentionExtension = Mention.extend({
name: "ticketMention",
group: "inline",
inline: true,
draggable: false,
selectable: true,
addAttributes() {
return {
id: { default: null },
@ -263,6 +268,48 @@ const TicketMentionExtension = Mention.extend({
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() {
return [
{