Align admin tables with ticket styling and add board view
This commit is contained in:
parent
63cf9f9d45
commit
a319aa0eff
8 changed files with 783 additions and 447 deletions
|
|
@ -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,107 +1248,105 @@ 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">
|
||||
<Checkbox
|
||||
checked={allTeamSelected || (someTeamSelected && "indeterminate")}
|
||||
onCheckedChange={(value) => toggleTeamSelectAll(!!value)}
|
||||
aria-label="Selecionar todos"
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600 first:pl-6">Nome</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">E-mail</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">Papel</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">Empresa</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">Máquinas</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">Criado em</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600 last:pr-6">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 bg-white">
|
||||
{teamPaginated.length > 0 ? (
|
||||
teamPaginated.map((user) => (
|
||||
<tr key={user.id} className="transition-colors hover:bg-slate-50/80">
|
||||
<td className="px-4 py-3 first:pl-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={teamSelection.has(user.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setTeamSelection((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) next.add(user.id)
|
||||
else next.delete(user.id)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
aria-label="Selecionar linha"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium text-neutral-800 first:pl-6">{user.name || "—"}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{user.email}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{formatRole(user.role)}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{user.companyName ?? "—"}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">
|
||||
{(() => {
|
||||
const list = machinesByUserEmail.get((user.email ?? "").toLowerCase()) ?? []
|
||||
if (list.length === 0) return "—"
|
||||
const names = list.map((m) => m.hostname || m.id)
|
||||
const head = names.slice(0, 2).join(", ")
|
||||
const extra = names.length > 2 ? ` +${names.length - 2}` : ""
|
||||
return (
|
||||
<span className="text-xs font-medium" title={names.join(", ")}>
|
||||
{head}
|
||||
{extra}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
|
||||
<td className="px-4 py-3 last:pr-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canManageUser(user.role)}
|
||||
onClick={() => {
|
||||
if (!canManageUser(user.role)) return
|
||||
setEditUserId(user.id)
|
||||
}}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
|
||||
disabled={!canManageUser(user.role)}
|
||||
onClick={() => {
|
||||
if (!canManageUser(user.role)) return
|
||||
setDeleteUserId(user.id)
|
||||
}}
|
||||
>
|
||||
Remover
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-6 py-6 text-center text-sm text-neutral-500">
|
||||
{teamUsers.length === 0
|
||||
? "Nenhum usuário cadastrado até o momento."
|
||||
: "Nenhum usuário corresponde aos filtros atuais."}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<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"
|
||||
/>
|
||||
</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) => (
|
||||
<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)}
|
||||
onCheckedChange={(checked) => {
|
||||
setTeamSelection((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) next.add(user.id)
|
||||
else next.delete(user.id)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
aria-label="Selecionar linha"
|
||||
/>
|
||||
</div>
|
||||
</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 "—"
|
||||
const names = list.map((m) => m.hostname || m.id)
|
||||
const head = names.slice(0, 2).join(", ")
|
||||
const extra = names.length > 2 ? ` +${names.length - 2}` : ""
|
||||
return (
|
||||
<span className="text-xs font-medium" title={names.join(", ")}>
|
||||
{head}
|
||||
{extra}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</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"
|
||||
disabled={!canManageUser(user.role)}
|
||||
onClick={() => {
|
||||
if (!canManageUser(user.role)) return
|
||||
setEditUserId(user.id)
|
||||
}}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
|
||||
disabled={!canManageUser(user.role)}
|
||||
onClick={() => {
|
||||
if (!canManageUser(user.role)) return
|
||||
setDeleteUserId(user.id)
|
||||
}}
|
||||
>
|
||||
Remover
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<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."}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</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,132 +1531,129 @@ 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">
|
||||
<Checkbox
|
||||
checked={
|
||||
usersSelection.size > 0 &&
|
||||
usersSelection.size === filteredCombinedUsers.length
|
||||
? true
|
||||
: usersSelection.size > 0
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(value) => toggleUsersCombinedSelectAll(!!value)}
|
||||
aria-label="Selecionar todos"
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600 first:pl-6">Nome</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">E-mail</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">Tipo</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">Perfil</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">Empresa</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">Criado em</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600 last:pr-6">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 bg-white">
|
||||
{usersPaginated.length > 0 ? (
|
||||
usersPaginated.map((user) => (
|
||||
<tr key={user.id} className="transition-colors hover:bg-slate-50/80">
|
||||
<td className="px-4 py-3 first:pl-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={usersSelection.has(user.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setUsersSelection((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) next.add(user.id)
|
||||
else next.delete(user.id)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
aria-label="Selecionar linha"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium text-neutral-800 first:pl-6">
|
||||
{user.name || (user.role === "machine" ? "Máquina" : "—")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{user.email}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">
|
||||
{user.role === "machine" ? "Máquina" : "Pessoa"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-600">
|
||||
{user.role === "machine" ? (
|
||||
user.machinePersona ? (
|
||||
<Badge
|
||||
variant={machinePersonaBadgeVariant(user.machinePersona)}
|
||||
className="rounded-full px-3 py-1 text-xs font-medium"
|
||||
>
|
||||
{formatMachinePersona(user.machinePersona)}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-neutral-500">Sem persona</span>
|
||||
)
|
||||
<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
|
||||
? true
|
||||
: usersSelection.size > 0
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(value) => toggleUsersCombinedSelectAll(!!value)}
|
||||
aria-label="Selecionar todos"
|
||||
/>
|
||||
</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) => (
|
||||
<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)}
|
||||
onCheckedChange={(checked) => {
|
||||
setUsersSelection((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) next.add(user.id)
|
||||
else next.delete(user.id)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
aria-label="Selecionar linha"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 font-medium text-neutral-800">
|
||||
{user.name || (user.role === "machine" ? "Máquina" : "—")}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-neutral-600">{user.email}</TableCell>
|
||||
<TableCell className="px-4 text-neutral-600">
|
||||
{user.role === "machine" ? "Máquina" : "Pessoa"}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-neutral-600">
|
||||
{user.role === "machine" ? (
|
||||
user.machinePersona ? (
|
||||
<Badge
|
||||
variant={machinePersonaBadgeVariant(user.machinePersona)}
|
||||
className="rounded-full px-3 py-1 text-xs font-medium"
|
||||
>
|
||||
{formatMachinePersona(user.machinePersona)}
|
||||
</Badge>
|
||||
) : (
|
||||
formatRole(user.role)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{user.companyName ?? "—"}</td>
|
||||
<td className="px-4 py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
|
||||
<td className="px-4 py-3 last:pr-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canManageUser(user.role)}
|
||||
onClick={() => {
|
||||
if (!canManageUser(user.role)) return
|
||||
setEditUserId(user.id)
|
||||
}}
|
||||
>
|
||||
Editar
|
||||
<span className="text-neutral-500">Sem persona</span>
|
||||
)
|
||||
) : (
|
||||
formatRole(user.role)
|
||||
)}
|
||||
</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"
|
||||
disabled={!canManageUser(user.role)}
|
||||
onClick={() => {
|
||||
if (!canManageUser(user.role)) return
|
||||
setEditUserId(user.id)
|
||||
}}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
{user.role === "machine" ? (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link
|
||||
href={
|
||||
extractMachineId(user.email)
|
||||
? `/admin/machines/${extractMachineId(user.email)}`
|
||||
: "/admin/machines"
|
||||
}
|
||||
>
|
||||
Detalhes da máquina
|
||||
</Link>
|
||||
</Button>
|
||||
{user.role === "machine" ? (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link
|
||||
href={
|
||||
extractMachineId(user.email)
|
||||
? `/admin/machines/${extractMachineId(user.email)}`
|
||||
: "/admin/machines"
|
||||
}
|
||||
>
|
||||
Detalhes da máquina
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
|
||||
disabled={!canManageUser(user.role)}
|
||||
onClick={() => {
|
||||
if (!canManageUser(user.role)) return
|
||||
setDeleteUserId(user.id)
|
||||
}}
|
||||
>
|
||||
Remover
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-6 py-6 text-center text-sm text-neutral-500">
|
||||
{combinedBaseUsers.length === 0
|
||||
? "Nenhum usuário cadastrado até o momento."
|
||||
: "Nenhum usuário corresponde aos filtros atuais."}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
|
||||
disabled={!canManageUser(user.role)}
|
||||
onClick={() => {
|
||||
if (!canManageUser(user.role)) return
|
||||
setDeleteUserId(user.id)
|
||||
}}
|
||||
>
|
||||
Remover
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<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."}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</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,101 +1807,99 @@ 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">
|
||||
<Checkbox
|
||||
checked={allInvitesSelected || (someInvitesSelected && "indeterminate")}
|
||||
onCheckedChange={(value) => toggleInvitesSelectAll(!!value)}
|
||||
aria-label="Selecionar todos"
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600 first:pl-6">Colaborador</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">Papel</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">Expira em</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600">Status</th>
|
||||
<th className="px-4 py-3 font-semibold text-neutral-600 last:pr-6">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 bg-white">
|
||||
{paginatedInvites.length > 0 ? (
|
||||
paginatedInvites.map((invite) => (
|
||||
<tr key={invite.id} className="transition-colors hover:bg-slate-50/80">
|
||||
<td className="px-4 py-3 first:pl-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={inviteSelection.has(invite.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setInviteSelection((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) next.add(invite.id)
|
||||
else next.delete(invite.id)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
aria-label="Selecionar linha"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 first:pl-6">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-neutral-800">{invite.name || invite.email}</span>
|
||||
<span className="text-xs text-neutral-500">{invite.email}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{formatRole(invite.role)}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{formatDate(invite.expiresAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
variant={inviteStatusVariant(invite.status)}
|
||||
className="rounded-full px-3 py-1 text-xs font-medium"
|
||||
>
|
||||
{formatStatus(invite.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 last:pr-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => handleCopy(invite.inviteUrl)}>
|
||||
Copiar link
|
||||
<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"
|
||||
/>
|
||||
</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) => (
|
||||
<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)}
|
||||
onCheckedChange={(checked) => {
|
||||
setInviteSelection((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) next.add(invite.id)
|
||||
else next.delete(invite.id)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
aria-label="Selecionar linha"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
{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>
|
||||
{invite.status === "pending" && canManageInvite(invite.role) ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 transition-colors hover:bg-red-500/10"
|
||||
onClick={() => setRevokeDialogInviteId(invite.id)}
|
||||
disabled={revokingId === invite.id}
|
||||
>
|
||||
{revokingId === invite.id ? "Revogando..." : "Revogar"}
|
||||
</Button>
|
||||
) : null}
|
||||
{invite.status === "revoked" && canReactivateInvitePolicy(invite) && canManageInvite(invite.role) ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-amber-400 text-amber-600 hover:bg-amber-50"
|
||||
onClick={() => handleReactivate(invite)}
|
||||
disabled={reactivatingId === invite.id}
|
||||
>
|
||||
{reactivatingId === invite.id ? "Reativando..." : "Reativar"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-6 text-center text-sm text-neutral-500">
|
||||
Nenhum convite emitido até o momento.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
) : null}
|
||||
{invite.status === "revoked" && canReactivateInvitePolicy(invite) && canManageInvite(invite.role) ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-amber-400 text-amber-600 hover:bg-amber-50"
|
||||
onClick={() => handleReactivate(invite)}
|
||||
disabled={reactivatingId === invite.id}
|
||||
>
|
||||
{reactivatingId === invite.id ? "Reativando..." : "Reativar"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="px-6 py-6 text-center text-sm text-neutral-500">
|
||||
Nenhum convite emitido até o momento.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,15 +563,13 @@ 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
|
||||
? customers.filter((customer) => !customer.companyId)
|
||||
: customers.filter((customer) => customer.companyId === normalizedValue)
|
||||
normalizedValue === NO_COMPANY_VALUE
|
||||
? customers.filter((customer) => !customer.companyId)
|
||||
: customers.filter((customer) => customer.companyId === normalizedValue)
|
||||
form.setValue("companyId", normalizedValue, {
|
||||
shouldDirty: normalizedValue !== companyValue,
|
||||
shouldTouch: true,
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
156
src/components/tickets/tickets-board.tsx
Normal file
156
src/components/tickets/tickets-board.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,21 +169,42 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
|
|||
assignees={(agents ?? []).map((a) => ({ id: a._id, name: a.name }))}
|
||||
initialState={mergedInitialFilters}
|
||||
/>
|
||||
<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"
|
||||
<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"
|
||||
>
|
||||
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>
|
||||
<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"
|
||||
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>
|
||||
{ticketsRaw === undefined ? (
|
||||
<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>
|
||||
) : viewMode === "board" ? (
|
||||
<TicketsBoard tickets={filteredTickets} />
|
||||
) : (
|
||||
<TicketsTable tickets={filteredTickets} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue