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,
|
PaginationNext,
|
||||||
PaginationPrevious,
|
PaginationPrevious,
|
||||||
} from "@/components/ui/pagination"
|
} from "@/components/ui/pagination"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { useQuery } from "convex/react"
|
import { useQuery } from "convex/react"
|
||||||
|
|
@ -35,6 +43,7 @@ import { api } from "@/convex/_generated/api"
|
||||||
import { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
|
import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
|
||||||
import { canReactivateInvite as canReactivateInvitePolicy } from "@/lib/invite-policies"
|
import { canReactivateInvite as canReactivateInvitePolicy } from "@/lib/invite-policies"
|
||||||
|
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||||
|
|
||||||
type AdminRole = RoleOption | "machine"
|
type AdminRole = RoleOption | "machine"
|
||||||
const NO_COMPANY_ID = "__none__"
|
const NO_COMPANY_ID = "__none__"
|
||||||
|
|
@ -284,6 +293,31 @@ export function AdminUsersManager({
|
||||||
)
|
)
|
||||||
const machineUsers = useMemo(() => users.filter((user) => user.role === "machine"), [users])
|
const machineUsers = useMemo(() => users.filter((user) => user.role === "machine"), [users])
|
||||||
|
|
||||||
|
const teamRoleFilterOptions = useMemo<SearchableComboboxOption[]>(() => {
|
||||||
|
const teamRoles = selectableRoles.filter((option) => ["admin", "agent"].includes(option))
|
||||||
|
return [
|
||||||
|
{ value: "all", label: "Todos os papéis" },
|
||||||
|
...teamRoles.map((option) => ({ value: option, label: formatRole(option) })),
|
||||||
|
]
|
||||||
|
}, [selectableRoles])
|
||||||
|
|
||||||
|
const usersTypeFilterOptions = useMemo<SearchableComboboxOption[]>(
|
||||||
|
() => [
|
||||||
|
{ value: "all", label: "Todos" },
|
||||||
|
{ value: "people", label: "Pessoas" },
|
||||||
|
{ value: "machines", label: "Máquinas" },
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const companyFilterOptions = useMemo<SearchableComboboxOption[]>(
|
||||||
|
() => [
|
||||||
|
{ value: "all", label: "Todas as empresas" },
|
||||||
|
...companies.map((company) => ({ value: company.id, label: company.name })),
|
||||||
|
],
|
||||||
|
[companies],
|
||||||
|
)
|
||||||
|
|
||||||
const defaultCreateRole: RoleOption = selectableRoles[0] ?? "agent"
|
const defaultCreateRole: RoleOption = selectableRoles[0] ?? "agent"
|
||||||
// Equipe
|
// Equipe
|
||||||
const [teamSearch, setTeamSearch] = useState("")
|
const [teamSearch, setTeamSearch] = useState("")
|
||||||
|
|
@ -1158,28 +1192,28 @@ async function handleDeleteUser() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-3 md:mt-0">
|
<div className="mt-3 flex flex-wrap items-center gap-3 md:mt-0">
|
||||||
<Select value={teamRoleFilter} onValueChange={(value) => setTeamRoleFilter(value as "all" | RoleOption)}>
|
<SearchableCombobox
|
||||||
<SelectTrigger className="h-9 w-full sm:w-48">
|
value={teamRoleFilter}
|
||||||
<SelectValue placeholder="Todos os papéis" />
|
onValueChange={(value) => {
|
||||||
</SelectTrigger>
|
if (value === "admin" || value === "agent") {
|
||||||
<SelectContent>
|
setTeamRoleFilter(value)
|
||||||
<SelectItem value="all">Todos os papéis</SelectItem>
|
return
|
||||||
{selectableRoles.filter((option) => ["admin", "agent"].includes(option)).map((option) => (
|
}
|
||||||
<SelectItem key={`team-filter-${option}`} value={option}>{formatRole(option)}</SelectItem>
|
setTeamRoleFilter("all")
|
||||||
))}
|
}}
|
||||||
</SelectContent>
|
options={teamRoleFilterOptions}
|
||||||
</Select>
|
placeholder="Todos os papéis"
|
||||||
<Select value={teamCompanyFilter} onValueChange={setTeamCompanyFilter}>
|
searchPlaceholder="Buscar papel..."
|
||||||
<SelectTrigger className="h-9 w-full sm:w-56">
|
className="md:w-48"
|
||||||
<SelectValue placeholder="Empresa" />
|
/>
|
||||||
</SelectTrigger>
|
<SearchableCombobox
|
||||||
<SelectContent>
|
value={teamCompanyFilter}
|
||||||
<SelectItem value="all">Todas as empresas</SelectItem>
|
onValueChange={(value) => setTeamCompanyFilter(value ?? "all")}
|
||||||
{companies.map((company) => (
|
options={companyFilterOptions}
|
||||||
<SelectItem key={`team-company-${company.id}`} value={company.id}>{company.name}</SelectItem>
|
placeholder="Todas as empresas"
|
||||||
))}
|
searchPlaceholder="Buscar empresa..."
|
||||||
</SelectContent>
|
className="md:w-56"
|
||||||
</Select>
|
/>
|
||||||
{/* Filtro por espaço removido */}
|
{/* Filtro por espaço removido */}
|
||||||
{(teamSearch.trim().length > 0 || teamRoleFilter !== "all") ? (
|
{(teamSearch.trim().length > 0 || teamRoleFilter !== "all") ? (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1214,107 +1248,105 @@ async function handleDeleteUser() {
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="w-full overflow-x-auto">
|
<div className="w-full overflow-x-auto">
|
||||||
<div className="overflow-hidden rounded-lg border">
|
<div className="overflow-hidden rounded-lg border">
|
||||||
<table className="min-w-full table-fixed text-sm">
|
<Table className="min-w-[960px] table-fixed text-sm">
|
||||||
<thead className="bg-slate-100/80">
|
<TableHeader className="bg-slate-100/80">
|
||||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-600">
|
<TableRow className="text-xs uppercase tracking-wide text-neutral-500">
|
||||||
<th className="w-10 px-4 py-3 font-semibold first:pl-6">
|
<TableHead className="w-12 px-4">
|
||||||
<div className="flex items-center justify-center">
|
<Checkbox
|
||||||
<Checkbox
|
checked={allTeamSelected || (someTeamSelected && "indeterminate")}
|
||||||
checked={allTeamSelected || (someTeamSelected && "indeterminate")}
|
onCheckedChange={(value) => toggleTeamSelectAll(!!value)}
|
||||||
onCheckedChange={(value) => toggleTeamSelectAll(!!value)}
|
aria-label="Selecionar todos"
|
||||||
aria-label="Selecionar todos"
|
/>
|
||||||
/>
|
</TableHead>
|
||||||
</div>
|
<TableHead className="px-4">Nome</TableHead>
|
||||||
</th>
|
<TableHead className="px-4">E-mail</TableHead>
|
||||||
<th className="px-4 py-3 font-semibold text-neutral-600 first:pl-6">Nome</th>
|
<TableHead className="px-4">Papel</TableHead>
|
||||||
<th className="px-4 py-3 font-semibold text-neutral-600">E-mail</th>
|
<TableHead className="px-4">Empresa</TableHead>
|
||||||
<th className="px-4 py-3 font-semibold text-neutral-600">Papel</th>
|
<TableHead className="px-4">Máquinas</TableHead>
|
||||||
<th className="px-4 py-3 font-semibold text-neutral-600">Empresa</th>
|
<TableHead className="px-4">Criado em</TableHead>
|
||||||
<th className="px-4 py-3 font-semibold text-neutral-600">Máquinas</th>
|
<TableHead className="px-4 text-right">Ações</TableHead>
|
||||||
<th className="px-4 py-3 font-semibold text-neutral-600">Criado em</th>
|
</TableRow>
|
||||||
<th className="px-4 py-3 font-semibold text-neutral-600 last:pr-6">Ações</th>
|
</TableHeader>
|
||||||
</tr>
|
<TableBody className="bg-white">
|
||||||
</thead>
|
{teamPaginated.length > 0 ? (
|
||||||
<tbody className="divide-y divide-slate-100 bg-white">
|
teamPaginated.map((user) => (
|
||||||
{teamPaginated.length > 0 ? (
|
<TableRow key={user.id} className="hover:bg-slate-100/70">
|
||||||
teamPaginated.map((user) => (
|
<TableCell className="w-12 px-4">
|
||||||
<tr key={user.id} className="transition-colors hover:bg-slate-50/80">
|
<div className="flex items-center justify-center">
|
||||||
<td className="px-4 py-3 first:pl-6">
|
<Checkbox
|
||||||
<div className="flex items-center justify-center">
|
checked={teamSelection.has(user.id)}
|
||||||
<Checkbox
|
onCheckedChange={(checked) => {
|
||||||
checked={teamSelection.has(user.id)}
|
setTeamSelection((prev) => {
|
||||||
onCheckedChange={(checked) => {
|
const next = new Set(prev)
|
||||||
setTeamSelection((prev) => {
|
if (checked) next.add(user.id)
|
||||||
const next = new Set(prev)
|
else next.delete(user.id)
|
||||||
if (checked) next.add(user.id)
|
return next
|
||||||
else next.delete(user.id)
|
})
|
||||||
return next
|
}}
|
||||||
})
|
aria-label="Selecionar linha"
|
||||||
}}
|
/>
|
||||||
aria-label="Selecionar linha"
|
</div>
|
||||||
/>
|
</TableCell>
|
||||||
</div>
|
<TableCell className="px-4 font-medium text-neutral-800">{user.name || "—"}</TableCell>
|
||||||
</td>
|
<TableCell className="px-4 text-neutral-600">{user.email}</TableCell>
|
||||||
<td className="px-4 py-3 font-medium text-neutral-800 first:pl-6">{user.name || "—"}</td>
|
<TableCell className="px-4 text-neutral-600">{formatRole(user.role)}</TableCell>
|
||||||
<td className="px-4 py-3 text-neutral-600">{user.email}</td>
|
<TableCell className="px-4 text-neutral-600">{user.companyName ?? "—"}</TableCell>
|
||||||
<td className="px-4 py-3 text-neutral-600">{formatRole(user.role)}</td>
|
<TableCell className="px-4 text-neutral-600">
|
||||||
<td className="px-4 py-3 text-neutral-600">{user.companyName ?? "—"}</td>
|
{(() => {
|
||||||
<td className="px-4 py-3 text-neutral-600">
|
const list = machinesByUserEmail.get((user.email ?? "").toLowerCase()) ?? []
|
||||||
{(() => {
|
if (list.length === 0) return "—"
|
||||||
const list = machinesByUserEmail.get((user.email ?? "").toLowerCase()) ?? []
|
const names = list.map((m) => m.hostname || m.id)
|
||||||
if (list.length === 0) return "—"
|
const head = names.slice(0, 2).join(", ")
|
||||||
const names = list.map((m) => m.hostname || m.id)
|
const extra = names.length > 2 ? ` +${names.length - 2}` : ""
|
||||||
const head = names.slice(0, 2).join(", ")
|
return (
|
||||||
const extra = names.length > 2 ? ` +${names.length - 2}` : ""
|
<span className="text-xs font-medium" title={names.join(", ")}>
|
||||||
return (
|
{head}
|
||||||
<span className="text-xs font-medium" title={names.join(", ")}>
|
{extra}
|
||||||
{head}
|
</span>
|
||||||
{extra}
|
)
|
||||||
</span>
|
})()}
|
||||||
)
|
</TableCell>
|
||||||
})()}
|
<TableCell className="px-4 text-neutral-500">{formatDate(user.createdAt)}</TableCell>
|
||||||
</td>
|
<TableCell className="px-4">
|
||||||
<td className="px-4 py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
<td className="px-4 py-3 last:pr-6">
|
<Button
|
||||||
<div className="flex flex-wrap gap-2">
|
variant="outline"
|
||||||
<Button
|
size="sm"
|
||||||
variant="outline"
|
disabled={!canManageUser(user.role)}
|
||||||
size="sm"
|
onClick={() => {
|
||||||
disabled={!canManageUser(user.role)}
|
if (!canManageUser(user.role)) return
|
||||||
onClick={() => {
|
setEditUserId(user.id)
|
||||||
if (!canManageUser(user.role)) return
|
}}
|
||||||
setEditUserId(user.id)
|
>
|
||||||
}}
|
Editar
|
||||||
>
|
</Button>
|
||||||
Editar
|
<Button
|
||||||
</Button>
|
variant="outline"
|
||||||
<Button
|
size="sm"
|
||||||
variant="outline"
|
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
|
||||||
size="sm"
|
disabled={!canManageUser(user.role)}
|
||||||
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
|
onClick={() => {
|
||||||
disabled={!canManageUser(user.role)}
|
if (!canManageUser(user.role)) return
|
||||||
onClick={() => {
|
setDeleteUserId(user.id)
|
||||||
if (!canManageUser(user.role)) return
|
}}
|
||||||
setDeleteUserId(user.id)
|
>
|
||||||
}}
|
Remover
|
||||||
>
|
</Button>
|
||||||
Remover
|
</div>
|
||||||
</Button>
|
</TableCell>
|
||||||
</div>
|
</TableRow>
|
||||||
</td>
|
))
|
||||||
</tr>
|
) : (
|
||||||
))
|
<TableRow>
|
||||||
) : (
|
<TableCell colSpan={8} className="px-6 py-6 text-center text-sm text-neutral-500">
|
||||||
<tr>
|
{teamUsers.length === 0
|
||||||
<td colSpan={8} className="px-6 py-6 text-center text-sm text-neutral-500">
|
? "Nenhum usuário cadastrado até o momento."
|
||||||
{teamUsers.length === 0
|
: "Nenhum usuário corresponde aos filtros atuais."}
|
||||||
? "Nenhum usuário cadastrado até o momento."
|
</TableCell>
|
||||||
: "Nenhum usuário corresponde aos filtros atuais."}
|
</TableRow>
|
||||||
</td>
|
)}
|
||||||
</tr>
|
</TableBody>
|
||||||
)}
|
</Table>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center justify-between gap-3 border-t border-slate-200 pt-2 text-xs text-neutral-500 md:flex-row md:text-sm">
|
<div className="flex flex-col items-center justify-between gap-3 border-t border-slate-200 pt-2 text-xs text-neutral-500 md:flex-row md:text-sm">
|
||||||
|
|
@ -1443,27 +1475,28 @@ async function handleDeleteUser() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-3 md:mt-0">
|
<div className="mt-3 flex flex-wrap items-center gap-3 md:mt-0">
|
||||||
<Select value={usersTypeFilter} onValueChange={(v) => setUsersTypeFilter(v as typeof usersTypeFilter)}>
|
<SearchableCombobox
|
||||||
<SelectTrigger className="h-9 w-full sm:w-40">
|
value={usersTypeFilter}
|
||||||
<SelectValue placeholder="Tipo" />
|
onValueChange={(value) => {
|
||||||
</SelectTrigger>
|
if (value === "people" || value === "machines") {
|
||||||
<SelectContent>
|
setUsersTypeFilter(value)
|
||||||
<SelectItem value="all">Todos</SelectItem>
|
return
|
||||||
<SelectItem value="people">Pessoas</SelectItem>
|
}
|
||||||
<SelectItem value="machines">Máquinas</SelectItem>
|
setUsersTypeFilter("all")
|
||||||
</SelectContent>
|
}}
|
||||||
</Select>
|
options={usersTypeFilterOptions}
|
||||||
<Select value={usersCompanyFilter} onValueChange={setUsersCompanyFilter}>
|
placeholder="Todos"
|
||||||
<SelectTrigger className="h-9 w-full sm:w-56">
|
searchPlaceholder="Buscar tipo..."
|
||||||
<SelectValue placeholder="Empresa" />
|
className="md:w-40"
|
||||||
</SelectTrigger>
|
/>
|
||||||
<SelectContent>
|
<SearchableCombobox
|
||||||
<SelectItem value="all">Todas as empresas</SelectItem>
|
value={usersCompanyFilter}
|
||||||
{companies.map((company) => (
|
onValueChange={(value) => setUsersCompanyFilter(value ?? "all")}
|
||||||
<SelectItem key={`users-company-${company.id}`} value={company.id}>{company.name}</SelectItem>
|
options={companyFilterOptions}
|
||||||
))}
|
placeholder="Todas as empresas"
|
||||||
</SelectContent>
|
searchPlaceholder="Buscar empresa..."
|
||||||
</Select>
|
className="md:w-56"
|
||||||
|
/>
|
||||||
{/* Filtro por espaço removido */}
|
{/* Filtro por espaço removido */}
|
||||||
{(usersSearch.trim().length > 0 || usersTypeFilter !== "all" || usersCompanyFilter !== "all") ? (
|
{(usersSearch.trim().length > 0 || usersTypeFilter !== "all" || usersCompanyFilter !== "all") ? (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1498,132 +1531,129 @@ async function handleDeleteUser() {
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="w-full overflow-x-auto">
|
<div className="w-full overflow-x-auto">
|
||||||
<div className="overflow-hidden rounded-lg border">
|
<div className="overflow-hidden rounded-lg border">
|
||||||
<table className="min-w-full table-fixed text-sm">
|
<Table className="min-w-[960px] table-fixed text-sm">
|
||||||
<thead className="bg-slate-100/80">
|
<TableHeader className="bg-slate-100/80">
|
||||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-600">
|
<TableRow className="text-xs uppercase tracking-wide text-neutral-500">
|
||||||
<th className="w-10 px-4 py-3 font-semibold first:pl-6">
|
<TableHead className="w-12 px-4">
|
||||||
<div className="flex items-center justify-center">
|
<Checkbox
|
||||||
<Checkbox
|
checked={
|
||||||
checked={
|
usersSelection.size > 0 && usersSelection.size === filteredCombinedUsers.length
|
||||||
usersSelection.size > 0 &&
|
? true
|
||||||
usersSelection.size === filteredCombinedUsers.length
|
: usersSelection.size > 0
|
||||||
? true
|
? "indeterminate"
|
||||||
: usersSelection.size > 0
|
: false
|
||||||
? "indeterminate"
|
}
|
||||||
: false
|
onCheckedChange={(value) => toggleUsersCombinedSelectAll(!!value)}
|
||||||
}
|
aria-label="Selecionar todos"
|
||||||
onCheckedChange={(value) => toggleUsersCombinedSelectAll(!!value)}
|
/>
|
||||||
aria-label="Selecionar todos"
|
</TableHead>
|
||||||
/>
|
<TableHead className="px-4">Nome</TableHead>
|
||||||
</div>
|
<TableHead className="px-4">E-mail</TableHead>
|
||||||
</th>
|
<TableHead className="px-4">Tipo</TableHead>
|
||||||
<th className="px-4 py-3 font-semibold text-neutral-600 first:pl-6">Nome</th>
|
<TableHead className="px-4">Perfil</TableHead>
|
||||||
<th className="px-4 py-3 font-semibold text-neutral-600">E-mail</th>
|
<TableHead className="px-4">Empresa</TableHead>
|
||||||
<th className="px-4 py-3 font-semibold text-neutral-600">Tipo</th>
|
<TableHead className="px-4">Criado em</TableHead>
|
||||||
<th className="px-4 py-3 font-semibold text-neutral-600">Perfil</th>
|
<TableHead className="px-4 text-right">Ações</TableHead>
|
||||||
<th className="px-4 py-3 font-semibold text-neutral-600">Empresa</th>
|
</TableRow>
|
||||||
<th className="px-4 py-3 font-semibold text-neutral-600">Criado em</th>
|
</TableHeader>
|
||||||
<th className="px-4 py-3 font-semibold text-neutral-600 last:pr-6">Ações</th>
|
<TableBody className="bg-white">
|
||||||
</tr>
|
{usersPaginated.length > 0 ? (
|
||||||
</thead>
|
usersPaginated.map((user) => (
|
||||||
<tbody className="divide-y divide-slate-100 bg-white">
|
<TableRow key={user.id} className="hover:bg-slate-100/70">
|
||||||
{usersPaginated.length > 0 ? (
|
<TableCell className="w-12 px-4">
|
||||||
usersPaginated.map((user) => (
|
<div className="flex items-center justify-center">
|
||||||
<tr key={user.id} className="transition-colors hover:bg-slate-50/80">
|
<Checkbox
|
||||||
<td className="px-4 py-3 first:pl-6">
|
checked={usersSelection.has(user.id)}
|
||||||
<div className="flex items-center justify-center">
|
onCheckedChange={(checked) => {
|
||||||
<Checkbox
|
setUsersSelection((prev) => {
|
||||||
checked={usersSelection.has(user.id)}
|
const next = new Set(prev)
|
||||||
onCheckedChange={(checked) => {
|
if (checked) next.add(user.id)
|
||||||
setUsersSelection((prev) => {
|
else next.delete(user.id)
|
||||||
const next = new Set(prev)
|
return next
|
||||||
if (checked) next.add(user.id)
|
})
|
||||||
else next.delete(user.id)
|
}}
|
||||||
return next
|
aria-label="Selecionar linha"
|
||||||
})
|
/>
|
||||||
}}
|
</div>
|
||||||
aria-label="Selecionar linha"
|
</TableCell>
|
||||||
/>
|
<TableCell className="px-4 font-medium text-neutral-800">
|
||||||
</div>
|
{user.name || (user.role === "machine" ? "Máquina" : "—")}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-4 py-3 font-medium text-neutral-800 first:pl-6">
|
<TableCell className="px-4 text-neutral-600">{user.email}</TableCell>
|
||||||
{user.name || (user.role === "machine" ? "Máquina" : "—")}
|
<TableCell className="px-4 text-neutral-600">
|
||||||
</td>
|
{user.role === "machine" ? "Máquina" : "Pessoa"}
|
||||||
<td className="px-4 py-3 text-neutral-600">{user.email}</td>
|
</TableCell>
|
||||||
<td className="px-4 py-3 text-neutral-600">
|
<TableCell className="px-4 text-neutral-600">
|
||||||
{user.role === "machine" ? "Máquina" : "Pessoa"}
|
{user.role === "machine" ? (
|
||||||
</td>
|
user.machinePersona ? (
|
||||||
<td className="px-4 py-3 text-neutral-600">
|
<Badge
|
||||||
{user.role === "machine" ? (
|
variant={machinePersonaBadgeVariant(user.machinePersona)}
|
||||||
user.machinePersona ? (
|
className="rounded-full px-3 py-1 text-xs font-medium"
|
||||||
<Badge
|
>
|
||||||
variant={machinePersonaBadgeVariant(user.machinePersona)}
|
{formatMachinePersona(user.machinePersona)}
|
||||||
className="rounded-full px-3 py-1 text-xs font-medium"
|
</Badge>
|
||||||
>
|
|
||||||
{formatMachinePersona(user.machinePersona)}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<span className="text-neutral-500">Sem persona</span>
|
|
||||||
)
|
|
||||||
) : (
|
) : (
|
||||||
formatRole(user.role)
|
<span className="text-neutral-500">Sem persona</span>
|
||||||
)}
|
)
|
||||||
</td>
|
) : (
|
||||||
<td className="px-4 py-3 text-neutral-600">{user.companyName ?? "—"}</td>
|
formatRole(user.role)
|
||||||
<td className="px-4 py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
|
)}
|
||||||
<td className="px-4 py-3 last:pr-6">
|
</TableCell>
|
||||||
<div className="flex flex-wrap gap-2">
|
<TableCell className="px-4 text-neutral-600">{user.companyName ?? "—"}</TableCell>
|
||||||
<Button
|
<TableCell className="px-4 text-neutral-500">{formatDate(user.createdAt)}</TableCell>
|
||||||
variant="outline"
|
<TableCell className="px-4">
|
||||||
size="sm"
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
disabled={!canManageUser(user.role)}
|
<Button
|
||||||
onClick={() => {
|
variant="outline"
|
||||||
if (!canManageUser(user.role)) return
|
size="sm"
|
||||||
setEditUserId(user.id)
|
disabled={!canManageUser(user.role)}
|
||||||
}}
|
onClick={() => {
|
||||||
>
|
if (!canManageUser(user.role)) return
|
||||||
Editar
|
setEditUserId(user.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
{user.role === "machine" ? (
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
extractMachineId(user.email)
|
||||||
|
? `/admin/machines/${extractMachineId(user.email)}`
|
||||||
|
: "/admin/machines"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Detalhes da máquina
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
{user.role === "machine" ? (
|
) : null}
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button
|
||||||
<Link
|
variant="outline"
|
||||||
href={
|
size="sm"
|
||||||
extractMachineId(user.email)
|
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
|
||||||
? `/admin/machines/${extractMachineId(user.email)}`
|
disabled={!canManageUser(user.role)}
|
||||||
: "/admin/machines"
|
onClick={() => {
|
||||||
}
|
if (!canManageUser(user.role)) return
|
||||||
>
|
setDeleteUserId(user.id)
|
||||||
Detalhes da máquina
|
}}
|
||||||
</Link>
|
>
|
||||||
</Button>
|
Remover
|
||||||
) : null}
|
</Button>
|
||||||
<Button
|
</div>
|
||||||
variant="outline"
|
</TableCell>
|
||||||
size="sm"
|
</TableRow>
|
||||||
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
|
))
|
||||||
disabled={!canManageUser(user.role)}
|
) : (
|
||||||
onClick={() => {
|
<TableRow>
|
||||||
if (!canManageUser(user.role)) return
|
<TableCell colSpan={8} className="px-6 py-6 text-center text-sm text-neutral-500">
|
||||||
setDeleteUserId(user.id)
|
{combinedBaseUsers.length === 0
|
||||||
}}
|
? "Nenhum usuário cadastrado até o momento."
|
||||||
>
|
: "Nenhum usuário corresponde aos filtros atuais."}
|
||||||
Remover
|
</TableCell>
|
||||||
</Button>
|
</TableRow>
|
||||||
</div>
|
)}
|
||||||
</td>
|
</TableBody>
|
||||||
</tr>
|
</Table>
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={8} className="px-6 py-6 text-center text-sm text-neutral-500">
|
|
||||||
{combinedBaseUsers.length === 0
|
|
||||||
? "Nenhum usuário cadastrado até o momento."
|
|
||||||
: "Nenhum usuário corresponde aos filtros atuais."}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center justify-between gap-3 border-t border-slate-200 pt-2 text-xs text-neutral-500 md:flex-row md:text-sm">
|
<div className="flex flex-col items-center justify-between gap-3 border-t border-slate-200 pt-2 text-xs text-neutral-500 md:flex-row md:text-sm">
|
||||||
|
|
@ -1777,101 +1807,99 @@ async function handleDeleteUser() {
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="w-full overflow-x-auto">
|
<div className="w-full overflow-x-auto">
|
||||||
<div className="overflow-hidden rounded-lg border">
|
<div className="overflow-hidden rounded-lg border">
|
||||||
<table className="min-w-full table-fixed text-sm">
|
<Table className="min-w-[800px] table-fixed text-sm">
|
||||||
<thead className="bg-slate-100/80">
|
<TableHeader className="bg-slate-100/80">
|
||||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-600">
|
<TableRow className="text-xs uppercase tracking-wide text-neutral-500">
|
||||||
<th className="w-10 px-4 py-3 font-semibold first:pl-6">
|
<TableHead className="w-12 px-4">
|
||||||
<div className="flex items-center justify-center">
|
<Checkbox
|
||||||
<Checkbox
|
checked={allInvitesSelected || (someInvitesSelected && "indeterminate")}
|
||||||
checked={allInvitesSelected || (someInvitesSelected && "indeterminate")}
|
onCheckedChange={(value) => toggleInvitesSelectAll(!!value)}
|
||||||
onCheckedChange={(value) => toggleInvitesSelectAll(!!value)}
|
aria-label="Selecionar todos"
|
||||||
aria-label="Selecionar todos"
|
/>
|
||||||
/>
|
</TableHead>
|
||||||
</div>
|
<TableHead className="px-4">Colaborador</TableHead>
|
||||||
</th>
|
<TableHead className="px-4">Papel</TableHead>
|
||||||
<th className="px-4 py-3 font-semibold text-neutral-600 first:pl-6">Colaborador</th>
|
<TableHead className="px-4">Expira em</TableHead>
|
||||||
<th className="px-4 py-3 font-semibold text-neutral-600">Papel</th>
|
<TableHead className="px-4">Status</TableHead>
|
||||||
<th className="px-4 py-3 font-semibold text-neutral-600">Expira em</th>
|
<TableHead className="px-4 text-right">Ações</TableHead>
|
||||||
<th className="px-4 py-3 font-semibold text-neutral-600">Status</th>
|
</TableRow>
|
||||||
<th className="px-4 py-3 font-semibold text-neutral-600 last:pr-6">Ações</th>
|
</TableHeader>
|
||||||
</tr>
|
<TableBody className="bg-white">
|
||||||
</thead>
|
{paginatedInvites.length > 0 ? (
|
||||||
<tbody className="divide-y divide-slate-100 bg-white">
|
paginatedInvites.map((invite) => (
|
||||||
{paginatedInvites.length > 0 ? (
|
<TableRow key={invite.id} className="hover:bg-slate-100/70">
|
||||||
paginatedInvites.map((invite) => (
|
<TableCell className="w-12 px-4">
|
||||||
<tr key={invite.id} className="transition-colors hover:bg-slate-50/80">
|
<div className="flex items-center justify-center">
|
||||||
<td className="px-4 py-3 first:pl-6">
|
<Checkbox
|
||||||
<div className="flex items-center justify-center">
|
checked={inviteSelection.has(invite.id)}
|
||||||
<Checkbox
|
onCheckedChange={(checked) => {
|
||||||
checked={inviteSelection.has(invite.id)}
|
setInviteSelection((prev) => {
|
||||||
onCheckedChange={(checked) => {
|
const next = new Set(prev)
|
||||||
setInviteSelection((prev) => {
|
if (checked) next.add(invite.id)
|
||||||
const next = new Set(prev)
|
else next.delete(invite.id)
|
||||||
if (checked) next.add(invite.id)
|
return next
|
||||||
else next.delete(invite.id)
|
})
|
||||||
return next
|
}}
|
||||||
})
|
aria-label="Selecionar linha"
|
||||||
}}
|
/>
|
||||||
aria-label="Selecionar linha"
|
</div>
|
||||||
/>
|
</TableCell>
|
||||||
</div>
|
<TableCell className="px-4">
|
||||||
</td>
|
<div className="flex flex-col">
|
||||||
<td className="px-4 py-3 first:pl-6">
|
<span className="font-medium text-neutral-800">{invite.name || invite.email}</span>
|
||||||
<div className="flex flex-col">
|
<span className="text-xs text-neutral-500">{invite.email}</span>
|
||||||
<span className="font-medium text-neutral-800">{invite.name || invite.email}</span>
|
</div>
|
||||||
<span className="text-xs text-neutral-500">{invite.email}</span>
|
</TableCell>
|
||||||
</div>
|
<TableCell className="px-4 text-neutral-600">{formatRole(invite.role)}</TableCell>
|
||||||
</td>
|
<TableCell className="px-4 text-neutral-600">{formatDate(invite.expiresAt)}</TableCell>
|
||||||
<td className="px-4 py-3 text-neutral-600">{formatRole(invite.role)}</td>
|
<TableCell className="px-4">
|
||||||
<td className="px-4 py-3 text-neutral-600">{formatDate(invite.expiresAt)}</td>
|
<Badge
|
||||||
<td className="px-4 py-3">
|
variant={inviteStatusVariant(invite.status)}
|
||||||
<Badge
|
className="rounded-full px-3 py-1 text-xs font-medium"
|
||||||
variant={inviteStatusVariant(invite.status)}
|
>
|
||||||
className="rounded-full px-3 py-1 text-xs font-medium"
|
{formatStatus(invite.status)}
|
||||||
>
|
</Badge>
|
||||||
{formatStatus(invite.status)}
|
</TableCell>
|
||||||
</Badge>
|
<TableCell className="px-4">
|
||||||
</td>
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
<td className="px-4 py-3 last:pr-6">
|
<Button variant="outline" size="sm" onClick={() => handleCopy(invite.inviteUrl)}>
|
||||||
<div className="flex flex-wrap gap-2">
|
Copiar link
|
||||||
<Button variant="outline" size="sm" onClick={() => handleCopy(invite.inviteUrl)}>
|
</Button>
|
||||||
Copiar link
|
{invite.status === "pending" && canManageInvite(invite.role) ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-red-600 transition-colors hover:bg-red-500/10"
|
||||||
|
onClick={() => setRevokeDialogInviteId(invite.id)}
|
||||||
|
disabled={revokingId === invite.id}
|
||||||
|
>
|
||||||
|
{revokingId === invite.id ? "Revogando..." : "Revogar"}
|
||||||
</Button>
|
</Button>
|
||||||
{invite.status === "pending" && canManageInvite(invite.role) ? (
|
) : null}
|
||||||
<Button
|
{invite.status === "revoked" && canReactivateInvitePolicy(invite) && canManageInvite(invite.role) ? (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
className="text-red-600 transition-colors hover:bg-red-500/10"
|
size="sm"
|
||||||
onClick={() => setRevokeDialogInviteId(invite.id)}
|
className="border-amber-400 text-amber-600 hover:bg-amber-50"
|
||||||
disabled={revokingId === invite.id}
|
onClick={() => handleReactivate(invite)}
|
||||||
>
|
disabled={reactivatingId === invite.id}
|
||||||
{revokingId === invite.id ? "Revogando..." : "Revogar"}
|
>
|
||||||
</Button>
|
{reactivatingId === invite.id ? "Reativando..." : "Reativar"}
|
||||||
) : null}
|
</Button>
|
||||||
{invite.status === "revoked" && canReactivateInvitePolicy(invite) && canManageInvite(invite.role) ? (
|
) : null}
|
||||||
<Button
|
</div>
|
||||||
variant="outline"
|
</TableCell>
|
||||||
size="sm"
|
</TableRow>
|
||||||
className="border-amber-400 text-amber-600 hover:bg-amber-50"
|
))
|
||||||
onClick={() => handleReactivate(invite)}
|
) : (
|
||||||
disabled={reactivatingId === invite.id}
|
<TableRow>
|
||||||
>
|
<TableCell colSpan={6} className="px-6 py-6 text-center text-sm text-neutral-500">
|
||||||
{reactivatingId === invite.id ? "Reativando..." : "Reativar"}
|
Nenhum convite emitido até o momento.
|
||||||
</Button>
|
</TableCell>
|
||||||
) : null}
|
</TableRow>
|
||||||
</div>
|
)}
|
||||||
</td>
|
</TableBody>
|
||||||
</tr>
|
</Table>
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="px-6 py-6 text-center text-sm text-neutral-500">
|
|
||||||
Nenhum convite emitido até o momento.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center justify-between gap-3 border-t border-slate-200 pt-2 text-xs text-neutral-500 md:flex-row md:text-sm">
|
<div className="flex flex-col items-center justify-between gap-3 border-t border-slate-200 pt-2 text-xs text-neutral-500 md:flex-row md:text-sm">
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,14 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
|
||||||
[rowSelection]
|
[rowSelection]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const visibleSelectedCount = useMemo(
|
||||||
|
() => filteredAccounts.reduce((total, account) => total + (rowSelection[account.id] ? 1 : 0), 0),
|
||||||
|
[filteredAccounts, rowSelection],
|
||||||
|
)
|
||||||
|
|
||||||
|
const allVisibleSelected = filteredAccounts.length > 0 && visibleSelectedCount === filteredAccounts.length
|
||||||
|
const isVisibleIndeterminate = visibleSelectedCount > 0 && visibleSelectedCount < filteredAccounts.length
|
||||||
|
|
||||||
const editAccount = useMemo(
|
const editAccount = useMemo(
|
||||||
() => (editAccountId ? accounts.find((account) => account.id === editAccountId) ?? null : null),
|
() => (editAccountId ? accounts.find((account) => account.id === editAccountId) ?? null : null),
|
||||||
[accounts, editAccountId]
|
[accounts, editAccountId]
|
||||||
|
|
@ -229,6 +237,19 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
|
||||||
|
|
||||||
const closeDeleteDialog = useCallback(() => setDeleteDialogIds([]), [])
|
const closeDeleteDialog = useCallback(() => setDeleteDialogIds([]), [])
|
||||||
|
|
||||||
|
const toggleVisibleSelection = useCallback(
|
||||||
|
(checked: boolean) => {
|
||||||
|
setRowSelection((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
filteredAccounts.forEach((account) => {
|
||||||
|
next[account.id] = checked
|
||||||
|
})
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[filteredAccounts],
|
||||||
|
)
|
||||||
|
|
||||||
const closeEditor = useCallback(() => {
|
const closeEditor = useCallback(() => {
|
||||||
setEditAccountId(null)
|
setEditAccountId(null)
|
||||||
setEditAuthUserId(null)
|
setEditAuthUserId(null)
|
||||||
|
|
@ -448,12 +469,18 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
|
||||||
<Table className="w-full table-fixed text-sm">
|
<Table className="w-full table-fixed text-sm">
|
||||||
<TableHeader className="bg-muted">
|
<TableHeader className="bg-muted">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="px-6">Usuário</TableHead>
|
<TableHead className="w-12 px-4">
|
||||||
|
<Checkbox
|
||||||
|
checked={isVisibleIndeterminate ? "indeterminate" : allVisibleSelected}
|
||||||
|
onCheckedChange={(checked) => toggleVisibleSelection(checked === true)}
|
||||||
|
aria-label="Selecionar todos os usuários visíveis"
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="min-w-[220px] px-4">Usuário</TableHead>
|
||||||
<TableHead className="px-4">Empresa</TableHead>
|
<TableHead className="px-4">Empresa</TableHead>
|
||||||
<TableHead className="px-4">Papel</TableHead>
|
<TableHead className="px-4">Papel</TableHead>
|
||||||
<TableHead className="px-4">Último acesso</TableHead>
|
<TableHead className="px-4">Último acesso</TableHead>
|
||||||
<TableHead className="px-4 text-right">Ações</TableHead>
|
<TableHead className="px-4 text-right">Ações</TableHead>
|
||||||
<TableHead className="px-4 text-right">Selecionar</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|
@ -473,7 +500,16 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
|
||||||
.join("")
|
.join("")
|
||||||
return (
|
return (
|
||||||
<TableRow key={account.id} className="hover:bg-muted/40">
|
<TableRow key={account.id} className="hover:bg-muted/40">
|
||||||
<TableCell className="px-6 py-3">
|
<TableCell className="w-12 px-4 py-3 align-middle">
|
||||||
|
<Checkbox
|
||||||
|
checked={rowSelection[account.id] ?? false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setRowSelection((prev) => ({ ...prev, [account.id]: checked === true }))
|
||||||
|
}
|
||||||
|
aria-label={`Selecionar ${account.name}`}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar className="size-9 border border-border/60">
|
<Avatar className="size-9 border border-border/60">
|
||||||
<AvatarFallback>{initials || account.email.charAt(0).toUpperCase()}</AvatarFallback>
|
<AvatarFallback>{initials || account.email.charAt(0).toUpperCase()}</AvatarFallback>
|
||||||
|
|
@ -516,17 +552,6 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-3 text-right align-middle">
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Checkbox
|
|
||||||
checked={rowSelection[account.id] ?? false}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setRowSelection((prev) => ({ ...prev, [account.id]: Boolean(checked) }))
|
|
||||||
}
|
|
||||||
aria-label={`Selecionar ${account.name}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,6 @@ function RequesterPreview({ customer, company }: RequesterPreviewProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const NO_COMPANY_VALUE = "__no_company__"
|
const NO_COMPANY_VALUE = "__no_company__"
|
||||||
const AUTO_COMPANY_VALUE = "__auto__"
|
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
subject: z.string().default(""),
|
subject: z.string().default(""),
|
||||||
|
|
@ -118,7 +117,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
||||||
channel: "MANUAL",
|
channel: "MANUAL",
|
||||||
queueName: null,
|
queueName: null,
|
||||||
assigneeId: null,
|
assigneeId: null,
|
||||||
companyId: AUTO_COMPANY_VALUE,
|
companyId: NO_COMPANY_VALUE,
|
||||||
requesterId: "",
|
requesterId: "",
|
||||||
categoryId: "",
|
categoryId: "",
|
||||||
subcategoryId: "",
|
subcategoryId: "",
|
||||||
|
|
@ -180,7 +179,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
||||||
const queueValue = form.watch("queueName") ?? "NONE"
|
const queueValue = form.watch("queueName") ?? "NONE"
|
||||||
const assigneeValue = form.watch("assigneeId") ?? null
|
const assigneeValue = form.watch("assigneeId") ?? null
|
||||||
const assigneeSelectValue = assigneeValue ?? "NONE"
|
const assigneeSelectValue = assigneeValue ?? "NONE"
|
||||||
const companyValue = form.watch("companyId") ?? AUTO_COMPANY_VALUE
|
const companyValue = form.watch("companyId") ?? NO_COMPANY_VALUE
|
||||||
const requesterValue = form.watch("requesterId") ?? ""
|
const requesterValue = form.watch("requesterId") ?? ""
|
||||||
const categoryIdValue = form.watch("categoryId")
|
const categoryIdValue = form.watch("categoryId")
|
||||||
const subcategoryIdValue = form.watch("subcategoryId")
|
const subcategoryIdValue = form.watch("subcategoryId")
|
||||||
|
|
@ -188,18 +187,25 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
||||||
const companyOptions = useMemo(() => {
|
const companyOptions = useMemo(() => {
|
||||||
const map = new Map<string, { id: string; name: string; isAvulso?: boolean; keywords: string[] }>()
|
const map = new Map<string, { id: string; name: string; isAvulso?: boolean; keywords: string[] }>()
|
||||||
companies.forEach((company) => {
|
companies.forEach((company) => {
|
||||||
|
const trimmedName = company.name.trim()
|
||||||
|
const slugFallback = company.slug?.trim()
|
||||||
|
const label =
|
||||||
|
trimmedName.length > 0 ? trimmedName : slugFallback && slugFallback.length > 0 ? slugFallback : `Empresa ${company.id.slice(0, 8)}`
|
||||||
map.set(company.id, {
|
map.set(company.id, {
|
||||||
id: company.id,
|
id: company.id,
|
||||||
name: company.name.trim().length > 0 ? company.name : "Empresa sem nome",
|
name: label,
|
||||||
isAvulso: false,
|
isAvulso: false,
|
||||||
keywords: company.slug ? [company.slug] : [],
|
keywords: company.slug ? [company.slug] : [],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
customers.forEach((customer) => {
|
customers.forEach((customer) => {
|
||||||
if (customer.companyId && !map.has(customer.companyId)) {
|
if (customer.companyId && !map.has(customer.companyId)) {
|
||||||
|
const trimmedName = customer.companyName?.trim() ?? ""
|
||||||
|
const label =
|
||||||
|
trimmedName.length > 0 ? trimmedName : `Empresa ${customer.companyId.slice(0, 8)}`
|
||||||
map.set(customer.companyId, {
|
map.set(customer.companyId, {
|
||||||
id: customer.companyId,
|
id: customer.companyId,
|
||||||
name: customer.companyName && customer.companyName.trim().length > 0 ? customer.companyName : "Empresa sem nome",
|
name: label,
|
||||||
isAvulso: customer.companyIsAvulso,
|
isAvulso: customer.companyIsAvulso,
|
||||||
keywords: [],
|
keywords: [],
|
||||||
})
|
})
|
||||||
|
|
@ -208,12 +214,12 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
||||||
const base: Array<{ id: string; name: string; isAvulso?: boolean; keywords: string[] }> = [
|
const base: Array<{ id: string; name: string; isAvulso?: boolean; keywords: string[] }> = [
|
||||||
{ id: NO_COMPANY_VALUE, name: "Sem empresa", keywords: ["sem empresa", "nenhuma"], isAvulso: false },
|
{ id: NO_COMPANY_VALUE, name: "Sem empresa", keywords: ["sem empresa", "nenhuma"], isAvulso: false },
|
||||||
]
|
]
|
||||||
const sorted = Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
|
const sorted = Array.from(map.values())
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
|
||||||
return [...base, ...sorted]
|
return [...base, ...sorted]
|
||||||
}, [companies, customers])
|
}, [companies, customers])
|
||||||
|
|
||||||
const filteredCustomers = useMemo(() => {
|
const filteredCustomers = useMemo(() => {
|
||||||
if (companyValue === AUTO_COMPANY_VALUE) return customers
|
|
||||||
if (companyValue === NO_COMPANY_VALUE) {
|
if (companyValue === NO_COMPANY_VALUE) {
|
||||||
return customers.filter((customer) => !customer.companyId)
|
return customers.filter((customer) => !customer.companyId)
|
||||||
}
|
}
|
||||||
|
|
@ -236,11 +242,10 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
||||||
[companyOptions],
|
[companyOptions],
|
||||||
)
|
)
|
||||||
|
|
||||||
const selectedCompanyOption = useMemo(() => {
|
const selectedCompanyOption = useMemo(
|
||||||
if (companyValue === AUTO_COMPANY_VALUE) return null
|
() => companyOptionMap.get(companyValue) ?? null,
|
||||||
const key = companyValue === NO_COMPANY_VALUE ? NO_COMPANY_VALUE : companyValue
|
[companyOptionMap, companyValue],
|
||||||
return companyOptionMap.get(key) ?? null
|
)
|
||||||
}, [companyOptionMap, companyValue])
|
|
||||||
|
|
||||||
const requesterById = useMemo(
|
const requesterById = useMemo(
|
||||||
() => new Map(customers.map((customer) => [customer.id, customer])),
|
() => new Map(customers.map((customer) => [customer.id, customer])),
|
||||||
|
|
@ -275,7 +280,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setCustomersInitialized(false)
|
setCustomersInitialized(false)
|
||||||
form.setValue("companyId", AUTO_COMPANY_VALUE, { shouldDirty: false, shouldTouch: false })
|
form.setValue("companyId", NO_COMPANY_VALUE, { shouldDirty: false, shouldTouch: false })
|
||||||
form.setValue("requesterId", "", { shouldDirty: false, shouldTouch: false })
|
form.setValue("requesterId", "", { shouldDirty: false, shouldTouch: false })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -294,7 +299,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
||||||
if (selected?.companyId) {
|
if (selected?.companyId) {
|
||||||
form.setValue("companyId", selected.companyId, { shouldDirty: false, shouldTouch: false })
|
form.setValue("companyId", selected.companyId, { shouldDirty: false, shouldTouch: false })
|
||||||
} else {
|
} else {
|
||||||
form.setValue("companyId", selected ? NO_COMPANY_VALUE : AUTO_COMPANY_VALUE, { shouldDirty: false, shouldTouch: false })
|
form.setValue("companyId", NO_COMPANY_VALUE, { shouldDirty: false, shouldTouch: false })
|
||||||
}
|
}
|
||||||
setCustomersInitialized(true)
|
setCustomersInitialized(true)
|
||||||
}, [open, customersInitialized, customers, convexUserId, form])
|
}, [open, customersInitialized, customers, convexUserId, form])
|
||||||
|
|
@ -558,15 +563,13 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Empresa</FieldLabel>
|
<FieldLabel>Empresa</FieldLabel>
|
||||||
<SearchableCombobox
|
<SearchableCombobox
|
||||||
value={companyValue === AUTO_COMPANY_VALUE ? null : companyValue}
|
value={companyValue}
|
||||||
onValueChange={(nextValue) => {
|
onValueChange={(nextValue) => {
|
||||||
const normalizedValue = nextValue ?? AUTO_COMPANY_VALUE
|
const normalizedValue = nextValue ?? NO_COMPANY_VALUE
|
||||||
const nextCustomers =
|
const nextCustomers =
|
||||||
normalizedValue === AUTO_COMPANY_VALUE
|
normalizedValue === NO_COMPANY_VALUE
|
||||||
? customers
|
? customers.filter((customer) => !customer.companyId)
|
||||||
: normalizedValue === NO_COMPANY_VALUE
|
: customers.filter((customer) => customer.companyId === normalizedValue)
|
||||||
? customers.filter((customer) => !customer.companyId)
|
|
||||||
: customers.filter((customer) => customer.companyId === normalizedValue)
|
|
||||||
form.setValue("companyId", normalizedValue, {
|
form.setValue("companyId", normalizedValue, {
|
||||||
shouldDirty: normalizedValue !== companyValue,
|
shouldDirty: normalizedValue !== companyValue,
|
||||||
shouldTouch: true,
|
shouldTouch: true,
|
||||||
|
|
@ -588,8 +591,6 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
||||||
}}
|
}}
|
||||||
options={companyComboboxOptions}
|
options={companyComboboxOptions}
|
||||||
placeholder="Selecionar empresa"
|
placeholder="Selecionar empresa"
|
||||||
allowClear
|
|
||||||
clearLabel="Qualquer empresa"
|
|
||||||
renderValue={(option) =>
|
renderValue={(option) =>
|
||||||
option ? (
|
option ? (
|
||||||
<span className="truncate">{option.label}</span>
|
<span className="truncate">{option.label}</span>
|
||||||
|
|
|
||||||
|
|
@ -256,8 +256,8 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
? "relative break-words rounded-xl border border-amber-200/80 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700 shadow-[0_0_0_1px_rgba(217,119,6,0.08)]"
|
? "relative break-words rounded-xl border border-amber-200/80 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700 shadow-[0_0_0_1px_rgba(217,119,6,0.08)]"
|
||||||
: "relative break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700"
|
: "relative break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700"
|
||||||
const bodyEditButtonClass = isPublic
|
const bodyEditButtonClass = isPublic
|
||||||
? "absolute right-2 top-2 inline-flex size-7 items-center justify-center rounded-full border border-amber-200 bg-white text-amber-700 opacity-0 transition hover:border-amber-500 hover:text-amber-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/30 group-hover/comment:opacity-100"
|
? "absolute right-3 top-1/2 inline-flex size-7 -translate-y-1/2 items-center justify-center rounded-full border border-amber-200 bg-white text-amber-700 opacity-0 transition hover:border-amber-500 hover:text-amber-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/30 group-hover/comment:opacity-100"
|
||||||
: "absolute right-2 top-2 inline-flex size-7 items-center justify-center rounded-full border border-slate-300 bg-white text-neutral-700 opacity-0 transition hover:border-black hover:text-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/20 group-hover/comment:opacity-100"
|
: "absolute right-3 top-1/2 inline-flex size-7 -translate-y-1/2 items-center justify-center rounded-full border border-slate-300 bg-white text-neutral-700 opacity-0 transition hover:border-black hover:text-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/20 group-hover/comment:opacity-100"
|
||||||
const addContentButtonClass = isPublic
|
const addContentButtonClass = isPublic
|
||||||
? "inline-flex items-center gap-2 text-sm font-medium text-amber-700 transition hover:text-amber-900"
|
? "inline-flex items-center gap-2 text-sm font-medium text-amber-700 transition hover:text-amber-900"
|
||||||
: "inline-flex items-center gap-2 text-sm font-medium text-neutral-600 transition hover:text-neutral-900"
|
: "inline-flex items-center gap-2 text-sm font-medium text-neutral-600 transition hover:text-neutral-900"
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import {
|
||||||
toServerTimestamp,
|
toServerTimestamp,
|
||||||
type SessionStartOrigin,
|
type SessionStartOrigin,
|
||||||
} from "./ticket-timer.utils"
|
} from "./ticket-timer.utils"
|
||||||
|
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||||
|
|
||||||
interface TicketHeaderProps {
|
interface TicketHeaderProps {
|
||||||
ticket: TicketWithDetails
|
ticket: TicketWithDetails
|
||||||
|
|
@ -109,7 +110,6 @@ type CustomerOption = {
|
||||||
avatarUrl: string | null
|
avatarUrl: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_COMPANIES_VALUE = "__all__"
|
|
||||||
const NO_COMPANY_VALUE = "__no_company__"
|
const NO_COMPANY_VALUE = "__no_company__"
|
||||||
const NO_REQUESTER_VALUE = "__no_requester__"
|
const NO_REQUESTER_VALUE = "__no_requester__"
|
||||||
|
|
||||||
|
|
@ -250,7 +250,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
const [closeOpen, setCloseOpen] = useState(false)
|
const [closeOpen, setCloseOpen] = useState(false)
|
||||||
const [assigneeChangeReason, setAssigneeChangeReason] = useState("")
|
const [assigneeChangeReason, setAssigneeChangeReason] = useState("")
|
||||||
const [assigneeReasonError, setAssigneeReasonError] = useState<string | null>(null)
|
const [assigneeReasonError, setAssigneeReasonError] = useState<string | null>(null)
|
||||||
const [companySelection, setCompanySelection] = useState<string>(ALL_COMPANIES_VALUE)
|
const [companySelection, setCompanySelection] = useState<string>(NO_COMPANY_VALUE)
|
||||||
const [requesterSelection, setRequesterSelection] = useState<string | null>(ticket.requester.id)
|
const [requesterSelection, setRequesterSelection] = useState<string | null>(ticket.requester.id)
|
||||||
const [requesterError, setRequesterError] = useState<string | null>(null)
|
const [requesterError, setRequesterError] = useState<string | null>(null)
|
||||||
const [customersInitialized, setCustomersInitialized] = useState(false)
|
const [customersInitialized, setCustomersInitialized] = useState(false)
|
||||||
|
|
@ -278,32 +278,47 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
if (ticket.company?.id) return String(ticket.company.id)
|
if (ticket.company?.id) return String(ticket.company.id)
|
||||||
return NO_COMPANY_VALUE
|
return NO_COMPANY_VALUE
|
||||||
}, [currentRequesterRecord, ticket.company?.id])
|
}, [currentRequesterRecord, ticket.company?.id])
|
||||||
const companyOptions = useMemo(() => {
|
const companyMeta = useMemo(() => {
|
||||||
const map = new Map<string, { id: string; name: string; isAvulso?: boolean }>()
|
const map = new Map<string, { name: string; isAvulso?: boolean; keywords: string[] }>()
|
||||||
companies.forEach((company) => {
|
companies.forEach((company) => {
|
||||||
map.set(company.id, { id: company.id, name: company.name, isAvulso: false })
|
const trimmedName = company.name.trim()
|
||||||
|
const slugFallback = company.slug?.trim()
|
||||||
|
const label =
|
||||||
|
trimmedName.length > 0
|
||||||
|
? trimmedName
|
||||||
|
: slugFallback && slugFallback.length > 0
|
||||||
|
? slugFallback
|
||||||
|
: `Empresa ${company.id.slice(0, 8)}`
|
||||||
|
const keywords = slugFallback ? [slugFallback] : []
|
||||||
|
map.set(company.id, { name: label, isAvulso: false, keywords })
|
||||||
})
|
})
|
||||||
customers.forEach((customer) => {
|
customers.forEach((customer) => {
|
||||||
if (customer.companyId && !map.has(customer.companyId)) {
|
if (customer.companyId && !map.has(customer.companyId)) {
|
||||||
|
const trimmedName = customer.companyName?.trim() ?? ""
|
||||||
|
const label =
|
||||||
|
trimmedName.length > 0 ? trimmedName : `Empresa ${customer.companyId.slice(0, 8)}`
|
||||||
map.set(customer.companyId, {
|
map.set(customer.companyId, {
|
||||||
id: customer.companyId,
|
name: label,
|
||||||
name: customer.companyName ?? "Empresa sem nome",
|
|
||||||
isAvulso: customer.companyIsAvulso,
|
isAvulso: customer.companyIsAvulso,
|
||||||
|
keywords: [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const includeNoCompany = customers.some((customer) => !customer.companyId)
|
return map
|
||||||
const result: Array<{ id: string; name: string; isAvulso?: boolean }> = [
|
|
||||||
{ id: ALL_COMPANIES_VALUE, name: "Todas as empresas" },
|
|
||||||
]
|
|
||||||
if (includeNoCompany) {
|
|
||||||
result.push({ id: NO_COMPANY_VALUE, name: "Sem empresa" })
|
|
||||||
}
|
|
||||||
const sorted = Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
|
|
||||||
return [...result, ...sorted]
|
|
||||||
}, [companies, customers])
|
}, [companies, customers])
|
||||||
|
|
||||||
|
const companyComboboxOptions = useMemo<SearchableComboboxOption[]>(() => {
|
||||||
|
const entries = Array.from(companyMeta.entries())
|
||||||
|
.map(([id, meta]) => ({
|
||||||
|
value: id,
|
||||||
|
label: meta.name,
|
||||||
|
keywords: meta.keywords,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label, "pt-BR"))
|
||||||
|
return [{ value: NO_COMPANY_VALUE, label: "Sem empresa", keywords: ["sem empresa", "nenhuma"] }, ...entries]
|
||||||
|
}, [companyMeta])
|
||||||
|
|
||||||
const filteredCustomers = useMemo(() => {
|
const filteredCustomers = useMemo(() => {
|
||||||
if (companySelection === ALL_COMPANIES_VALUE) return customers
|
|
||||||
if (companySelection === NO_COMPANY_VALUE) {
|
if (companySelection === NO_COMPANY_VALUE) {
|
||||||
return customers.filter((customer) => !customer.companyId)
|
return customers.filter((customer) => !customer.companyId)
|
||||||
}
|
}
|
||||||
|
|
@ -312,6 +327,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
const assigneeDirty = useMemo(() => assigneeSelection !== currentAssigneeId, [assigneeSelection, currentAssigneeId])
|
const assigneeDirty = useMemo(() => assigneeSelection !== currentAssigneeId, [assigneeSelection, currentAssigneeId])
|
||||||
const requesterDirty = useMemo(() => requesterSelection !== ticket.requester.id, [requesterSelection, ticket.requester.id])
|
const requesterDirty = useMemo(() => requesterSelection !== ticket.requester.id, [requesterSelection, ticket.requester.id])
|
||||||
const formDirty = dirty || categoryDirty || queueDirty || assigneeDirty || requesterDirty
|
const formDirty = dirty || categoryDirty || queueDirty || assigneeDirty || requesterDirty
|
||||||
|
const assigneeReasonRequired = assigneeDirty && !isManager
|
||||||
|
const assigneeReasonValid = !assigneeReasonRequired || assigneeChangeReason.trim().length >= 5
|
||||||
|
const saveDisabled = !formDirty || saving || !assigneeReasonValid
|
||||||
const companyLabel = useMemo(() => {
|
const companyLabel = useMemo(() => {
|
||||||
if (ticket.company?.name) return ticket.company.name
|
if (ticket.company?.name) return ticket.company.name
|
||||||
if (isAvulso) return "Cliente avulso"
|
if (isAvulso) return "Cliente avulso"
|
||||||
|
|
@ -1238,23 +1256,35 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className={sectionLabelClass}>Empresa</span>
|
<span className={sectionLabelClass}>Empresa</span>
|
||||||
{editing && !isManager ? (
|
{editing && !isManager ? (
|
||||||
<Select
|
<SearchableCombobox
|
||||||
value={companySelection}
|
value={companySelection}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
setCompanySelection(value)
|
setCompanySelection(value ?? NO_COMPANY_VALUE)
|
||||||
}}
|
}}
|
||||||
>
|
options={companyComboboxOptions}
|
||||||
<SelectTrigger className={selectTriggerClass}>
|
placeholder="Selecionar empresa"
|
||||||
<SelectValue placeholder="Selecionar" />
|
disabled={isManager}
|
||||||
</SelectTrigger>
|
renderValue={(option) =>
|
||||||
<SelectContent className="max-h-64 rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
option ? (
|
||||||
{companyOptions.map((option) => (
|
<span className="truncate">{option.label}</span>
|
||||||
<SelectItem key={option.id} value={option.id}>
|
) : (
|
||||||
{option.name}
|
<span className="text-muted-foreground">Selecionar empresa</span>
|
||||||
</SelectItem>
|
)
|
||||||
))}
|
}
|
||||||
</SelectContent>
|
renderOption={(option) => {
|
||||||
</Select>
|
const meta = companyMeta.get(option.value)
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="font-medium text-foreground">{option.label}</span>
|
||||||
|
{meta?.isAvulso ? (
|
||||||
|
<Badge variant="outline" className="rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide">
|
||||||
|
Avulsa
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className={sectionValueClass}>{companyLabel}</span>
|
<span className={sectionValueClass}>{companyLabel}</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1350,6 +1380,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
</p>
|
</p>
|
||||||
{assigneeReasonError ? (
|
{assigneeReasonError ? (
|
||||||
<p className="text-xs font-semibold text-rose-600">{assigneeReasonError}</p>
|
<p className="text-xs font-semibold text-rose-600">{assigneeReasonError}</p>
|
||||||
|
) : assigneeReasonRequired && assigneeChangeReason.trim().length < 5 ? (
|
||||||
|
<p className="text-xs font-semibold text-rose-600">Descreva o motivo com pelo menos 5 caracteres.</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -1381,7 +1413,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
<Button variant="ghost" size="sm" className="text-sm font-semibold text-neutral-700" onClick={handleCancel}>
|
<Button variant="ghost" size="sm" className="text-sm font-semibold text-neutral-700" onClick={handleCancel}>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" className={startButtonClass} onClick={handleSave} disabled={!formDirty || saving}>
|
<Button size="sm" className={startButtonClass} onClick={handleSave} disabled={saveDisabled}>
|
||||||
Salvar
|
Salvar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
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 type { Ticket, TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||||
import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters"
|
import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters"
|
||||||
import { TicketsTable } from "@/components/tickets/tickets-table"
|
import { TicketsTable } from "@/components/tickets/tickets-table"
|
||||||
|
import { TicketsBoard } from "@/components/tickets/tickets-board"
|
||||||
import { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
import { useDefaultQueues } from "@/hooks/use-default-queues"
|
import { useDefaultQueues } from "@/hooks/use-default-queues"
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||||
|
import { LayoutGrid, List } from "lucide-react"
|
||||||
|
|
||||||
type TicketsViewProps = {
|
type TicketsViewProps = {
|
||||||
initialFilters?: Partial<TicketFiltersState>
|
initialFilters?: Partial<TicketFiltersState>
|
||||||
|
|
@ -26,10 +29,31 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
|
||||||
[initialFilters]
|
[initialFilters]
|
||||||
)
|
)
|
||||||
const [filters, setFilters] = useState<TicketFiltersState>(mergedInitialFilters)
|
const [filters, setFilters] = useState<TicketFiltersState>(mergedInitialFilters)
|
||||||
|
const [viewMode, setViewMode] = useState<"table" | "board">("table")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFilters(mergedInitialFilters)
|
setFilters(mergedInitialFilters)
|
||||||
}, [mergedInitialFilters])
|
}, [mergedInitialFilters])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem("tickets:view-mode")
|
||||||
|
if (stored === "table" || stored === "board") {
|
||||||
|
setViewMode(stored)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem("tickets:view-mode", viewMode)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [viewMode])
|
||||||
|
|
||||||
const { session, convexUserId, isStaff } = useAuth()
|
const { session, convexUserId, isStaff } = useAuth()
|
||||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
||||||
|
|
@ -145,21 +169,42 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
|
||||||
assignees={(agents ?? []).map((a) => ({ id: a._id, name: a.name }))}
|
assignees={(agents ?? []).map((a) => ({ id: a._id, name: a.name }))}
|
||||||
initialState={mergedInitialFilters}
|
initialState={mergedInitialFilters}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<button
|
<ToggleGroup
|
||||||
type="button"
|
type="single"
|
||||||
onClick={handleSaveDefault}
|
value={viewMode}
|
||||||
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-neutral-800 hover:bg-slate-50"
|
onValueChange={(next) => {
|
||||||
|
if (!next) return
|
||||||
|
setViewMode(next as "table" | "board")
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
className="inline-flex rounded-md border border-border/60 bg-muted/30"
|
||||||
>
|
>
|
||||||
Salvar filtro como padrão
|
<ToggleGroupItem value="table" aria-label="Listagem em tabela" className="min-w-[96px] justify-center gap-2">
|
||||||
</button>
|
<List className="size-4" />
|
||||||
<button
|
<span>Tabela</span>
|
||||||
type="button"
|
</ToggleGroupItem>
|
||||||
onClick={handleClearDefault}
|
<ToggleGroupItem value="board" aria-label="Visão em quadro" className="min-w-[96px] justify-center gap-2">
|
||||||
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-neutral-600 hover:bg-slate-50"
|
<LayoutGrid className="size-4" />
|
||||||
>
|
<span>Quadro</span>
|
||||||
Limpar padrão
|
</ToggleGroupItem>
|
||||||
</button>
|
</ToggleGroup>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSaveDefault}
|
||||||
|
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-neutral-800 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Salvar filtro como padrão
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearDefault}
|
||||||
|
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-neutral-600 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Limpar padrão
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{ticketsRaw === undefined ? (
|
{ticketsRaw === undefined ? (
|
||||||
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
|
|
@ -172,6 +217,8 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : viewMode === "board" ? (
|
||||||
|
<TicketsBoard tickets={filteredTickets} />
|
||||||
) : (
|
) : (
|
||||||
<TicketsTable tickets={filteredTickets} />
|
<TicketsTable tickets={filteredTickets} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
} from "react"
|
} from "react"
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import { useEditor, EditorContent } from "@tiptap/react"
|
import { useEditor, EditorContent } from "@tiptap/react"
|
||||||
|
import type { Editor } from "@tiptap/react"
|
||||||
import StarterKit from "@tiptap/starter-kit"
|
import StarterKit from "@tiptap/starter-kit"
|
||||||
import Placeholder from "@tiptap/extension-placeholder"
|
import Placeholder from "@tiptap/extension-placeholder"
|
||||||
import Mention from "@tiptap/extension-mention"
|
import Mention from "@tiptap/extension-mention"
|
||||||
|
|
@ -253,6 +254,10 @@ const TicketMentionListComponent = (props: TicketMentionSuggestionProps) => (
|
||||||
|
|
||||||
const TicketMentionExtension = Mention.extend({
|
const TicketMentionExtension = Mention.extend({
|
||||||
name: "ticketMention",
|
name: "ticketMention",
|
||||||
|
group: "inline",
|
||||||
|
inline: true,
|
||||||
|
draggable: false,
|
||||||
|
selectable: true,
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
id: { default: null },
|
id: { default: null },
|
||||||
|
|
@ -263,6 +268,48 @@ const TicketMentionExtension = Mention.extend({
|
||||||
url: { default: null },
|
url: { default: null },
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
const mentionPrototype = Mention as unknown as {
|
||||||
|
prototype: { addKeyboardShortcuts?: (this: unknown) => unknown }
|
||||||
|
}
|
||||||
|
const parentShortcuts = mentionPrototype.prototype.addKeyboardShortcuts?.call(this) as
|
||||||
|
| Record<string, (args: { editor: Editor }) => boolean>
|
||||||
|
| undefined
|
||||||
|
const parent = parentShortcuts ?? {}
|
||||||
|
return {
|
||||||
|
...parent,
|
||||||
|
Backspace: ({ editor }: { editor: Editor }) => {
|
||||||
|
const { state } = editor
|
||||||
|
const { selection } = state
|
||||||
|
if (selection.empty) {
|
||||||
|
const { $from } = selection
|
||||||
|
const nodeBefore = $from.nodeBefore
|
||||||
|
if (nodeBefore?.type?.name === this.name) {
|
||||||
|
const from = $from.pos - nodeBefore.nodeSize
|
||||||
|
const to = $from.pos
|
||||||
|
editor.chain().focus().deleteRange({ from, to }).run()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parent.Backspace ? parent.Backspace({ editor }) : false
|
||||||
|
},
|
||||||
|
Delete: ({ editor }: { editor: Editor }) => {
|
||||||
|
const { state } = editor
|
||||||
|
const { selection } = state
|
||||||
|
if (selection.empty) {
|
||||||
|
const { $from } = selection
|
||||||
|
const nodeAfter = $from.nodeAfter
|
||||||
|
if (nodeAfter?.type?.name === this.name) {
|
||||||
|
const from = $from.pos
|
||||||
|
const to = $from.pos + nodeAfter.nodeSize
|
||||||
|
editor.chain().focus().deleteRange({ from, to }).run()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parent.Delete ? parent.Delete({ editor }) : false
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue