Improve admin actions and ticket board layout
This commit is contained in:
parent
92ac0fafc6
commit
12a6d231fa
4 changed files with 59 additions and 96 deletions
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useCallback, useEffect, useMemo, useState, useTransition } from "react"
|
import { useCallback, useEffect, useMemo, useState, useTransition } from "react"
|
||||||
import { IconSearch, IconUserPlus, IconTrash, IconAlertTriangle } from "@tabler/icons-react"
|
import { IconSearch, IconUserPlus, IconTrash, IconAlertTriangle, IconPencil } from "@tabler/icons-react"
|
||||||
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
|
@ -1248,10 +1248,10 @@ 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-[960px] table-fixed text-sm">
|
<Table className="min-w-[900px] table-auto text-sm">
|
||||||
<TableHeader className="bg-slate-100/80">
|
<TableHeader className="bg-slate-100/80">
|
||||||
<TableRow className="text-xs uppercase tracking-wide text-neutral-500">
|
<TableRow className="text-xs uppercase tracking-wide text-neutral-500">
|
||||||
<TableHead className="w-12 px-4">
|
<TableHead className="w-16 pl-6 pr-4">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={allTeamSelected || (someTeamSelected && "indeterminate")}
|
checked={allTeamSelected || (someTeamSelected && "indeterminate")}
|
||||||
onCheckedChange={(value) => toggleTeamSelectAll(!!value)}
|
onCheckedChange={(value) => toggleTeamSelectAll(!!value)}
|
||||||
|
|
@ -1259,10 +1259,9 @@ async function handleDeleteUser() {
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="px-4">Nome</TableHead>
|
<TableHead className="px-4">Nome</TableHead>
|
||||||
<TableHead className="px-4">E-mail</TableHead>
|
<TableHead className="px-4 md:w-72">E-mail</TableHead>
|
||||||
<TableHead className="px-4">Papel</TableHead>
|
<TableHead className="px-4 md:w-44">Papel</TableHead>
|
||||||
<TableHead className="px-4">Empresa</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">Criado em</TableHead>
|
||||||
<TableHead className="px-4 text-right">Ações</TableHead>
|
<TableHead className="px-4 text-right">Ações</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -1271,8 +1270,8 @@ async function handleDeleteUser() {
|
||||||
{teamPaginated.length > 0 ? (
|
{teamPaginated.length > 0 ? (
|
||||||
teamPaginated.map((user) => (
|
teamPaginated.map((user) => (
|
||||||
<TableRow key={user.id} className="hover:bg-slate-100/70">
|
<TableRow key={user.id} className="hover:bg-slate-100/70">
|
||||||
<TableCell className="w-12 px-4">
|
<TableCell className="w-16 pl-6 pr-4">
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-start">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={teamSelection.has(user.id)}
|
checked={teamSelection.has(user.id)}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
|
|
@ -1288,49 +1287,37 @@ async function handleDeleteUser() {
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 font-medium text-neutral-800">{user.name || "—"}</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 break-words">{user.email}</TableCell>
|
||||||
<TableCell className="px-4 text-neutral-600">{formatRole(user.role)}</TableCell>
|
<TableCell className="px-4 text-neutral-600 whitespace-nowrap">{formatRole(user.role)}</TableCell>
|
||||||
<TableCell className="px-4 text-neutral-600">{user.companyName ?? "—"}</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 text-neutral-500">{formatDate(user.createdAt)}</TableCell>
|
||||||
<TableCell className="px-4">
|
<TableCell className="px-4">
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
size="icon"
|
||||||
size="sm"
|
aria-label="Editar usuário"
|
||||||
|
title="Editar usuário"
|
||||||
|
className="h-9 w-9 rounded-lg border border-slate-200 bg-white text-neutral-800 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-200 disabled:pointer-events-none disabled:opacity-50"
|
||||||
disabled={!canManageUser(user.role)}
|
disabled={!canManageUser(user.role)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!canManageUser(user.role)) return
|
if (!canManageUser(user.role)) return
|
||||||
setEditUserId(user.id)
|
setEditUserId(user.id)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Editar
|
<IconPencil className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
size="icon"
|
||||||
size="sm"
|
aria-label="Remover usuário"
|
||||||
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
|
title="Remover usuário"
|
||||||
|
className="h-9 w-9 rounded-lg border border-rose-300 bg-white text-rose-600 transition hover:bg-rose-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 disabled:pointer-events-none disabled:opacity-50"
|
||||||
disabled={!canManageUser(user.role)}
|
disabled={!canManageUser(user.role)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!canManageUser(user.role)) return
|
if (!canManageUser(user.role)) return
|
||||||
setDeleteUserId(user.id)
|
setDeleteUserId(user.id)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Remover
|
<IconTrash className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -1338,7 +1325,7 @@ async function handleDeleteUser() {
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} className="px-6 py-6 text-center text-sm text-neutral-500">
|
<TableCell colSpan={7} className="px-6 py-6 text-center text-sm text-neutral-500">
|
||||||
{teamUsers.length === 0
|
{teamUsers.length === 0
|
||||||
? "Nenhum usuário cadastrado até o momento."
|
? "Nenhum usuário cadastrado até o momento."
|
||||||
: "Nenhum usuário corresponde aos filtros atuais."}
|
: "Nenhum usuário corresponde aos filtros atuais."}
|
||||||
|
|
|
||||||
|
|
@ -868,9 +868,14 @@ function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableVi
|
||||||
<IconDeviceDesktop className="mr-2 size-3.5" />
|
<IconDeviceDesktop className="mr-2 size-3.5" />
|
||||||
Máquinas
|
Máquinas
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="secondary" className="whitespace-nowrap" onClick={() => onEdit(company)}>
|
<Button
|
||||||
<IconPencil className="mr-2 size-3.5" />
|
size="icon"
|
||||||
Editar
|
aria-label="Editar empresa"
|
||||||
|
title="Editar empresa"
|
||||||
|
className="h-9 w-9 rounded-lg border border-slate-200 bg-white text-neutral-800 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-200"
|
||||||
|
onClick={() => onEdit(company)}
|
||||||
|
>
|
||||||
|
<IconPencil className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@ import { formatDistanceToNowStrict } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
import { LayoutGrid } from "lucide-react"
|
import { LayoutGrid } from "lucide-react"
|
||||||
|
|
||||||
import type { Ticket, TicketPriority, TicketStatus } from "@/lib/schemas/ticket"
|
import type { Ticket, TicketStatus } from "@/lib/schemas/ticket"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"
|
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
|
||||||
|
|
||||||
type TicketsBoardProps = {
|
type TicketsBoardProps = {
|
||||||
tickets: Ticket[]
|
tickets: Ticket[]
|
||||||
|
|
@ -28,20 +29,6 @@ const statusChipClass: Record<TicketStatus, string> = {
|
||||||
RESOLVED: "bg-emerald-100 text-emerald-700 ring-1 ring-emerald-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) {
|
function formatUpdated(date: Date) {
|
||||||
return formatDistanceToNowStrict(date, { addSuffix: true, locale: ptBR })
|
return formatDistanceToNowStrict(date, { addSuffix: true, locale: ptBR })
|
||||||
}
|
}
|
||||||
|
|
@ -71,84 +58,63 @@ export function TicketsBoard({ tickets }: TicketsBoardProps) {
|
||||||
<Link
|
<Link
|
||||||
key={ticket.id}
|
key={ticket.id}
|
||||||
href={`/tickets/${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"
|
className="group block h-full rounded-3xl border border-slate-200 bg-white p-6 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 items-start justify-between gap-4">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-slate-200 bg-slate-100 px-2.5 py-1 text-[11px] font-semibold text-neutral-700"
|
className="border-slate-200 bg-slate-100 px-3.5 py-1.5 text-xs font-semibold text-neutral-700"
|
||||||
>
|
>
|
||||||
#{ticket.reference}
|
#{ticket.reference}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-semibold transition",
|
"inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-semibold transition",
|
||||||
statusChipClass[ticket.status],
|
statusChipClass[ticket.status],
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{statusLabel[ticket.status]}
|
{statusLabel[ticket.status]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-neutral-400">
|
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||||
{formatUpdated(ticket.updatedAt)}
|
{formatUpdated(ticket.updatedAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mt-3 line-clamp-2 text-sm font-semibold text-neutral-900">
|
<h3 className="mt-5 line-clamp-2 text-lg font-semibold text-neutral-900">
|
||||||
{ticket.subject || "Sem assunto"}
|
{ticket.subject || "Sem assunto"}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-neutral-600">
|
<div className="mt-3 flex flex-wrap items-center gap-3 text-sm text-neutral-600">
|
||||||
<span className="font-medium text-neutral-500">Fila:</span>
|
<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">
|
<span className="rounded-full bg-slate-100 px-3.5 py-0.5 text-xs font-medium text-neutral-700">
|
||||||
{ticket.queue ?? "Sem fila"}
|
{ticket.queue ?? "Sem fila"}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium text-neutral-500">Prioridade:</span>
|
<span className="font-medium text-neutral-500">Prioridade:</span>
|
||||||
<span
|
<TicketPriorityPill
|
||||||
className={cn(
|
priority={ticket.priority}
|
||||||
"rounded-full px-2.5 py-0.5 text-[11px] font-semibold shadow-sm",
|
className="h-7 gap-1.5 px-3.5 text-xs shadow-sm"
|
||||||
priorityChipClass[ticket.priority],
|
/>
|
||||||
)}
|
|
||||||
>
|
|
||||||
{priorityLabel[ticket.priority]}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<dl className="mt-4 space-y-2 text-xs text-neutral-600">
|
<dl className="mt-6 space-y-2.5 text-sm text-neutral-600">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<dt className="font-medium text-neutral-500">Empresa</dt>
|
<dt className="font-medium text-neutral-500">Empresa</dt>
|
||||||
<dd className="truncate text-right text-neutral-700">
|
<dd className="truncate text-right text-neutral-700">
|
||||||
{ticket.company?.name ?? "Sem empresa"}
|
{ticket.company?.name ?? "Sem empresa"}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<dt className="font-medium text-neutral-500">Responsável</dt>
|
<dt className="font-medium text-neutral-500">Responsável</dt>
|
||||||
<dd className="truncate text-right text-neutral-700">
|
<dd className="truncate text-right text-neutral-700">
|
||||||
{ticket.assignee?.name ?? "Sem responsável"}
|
{ticket.assignee?.name ?? "Sem responsável"}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<dt className="font-medium text-neutral-500">Solicitante</dt>
|
<dt className="font-medium text-neutral-500">Solicitante</dt>
|
||||||
<dd className="truncate text-right text-neutral-700">
|
<dd className="truncate text-right text-neutral-700">
|
||||||
{ticket.requester?.name ?? ticket.requester?.email ?? "—"}
|
{ticket.requester?.name ?? ticket.requester?.email ?? "—"}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</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>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -35,27 +35,32 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
|
||||||
setFilters(mergedInitialFilters)
|
setFilters(mergedInitialFilters)
|
||||||
}, [mergedInitialFilters])
|
}, [mergedInitialFilters])
|
||||||
|
|
||||||
|
const { session, convexUserId, isStaff } = useAuth()
|
||||||
|
const userId = session?.user?.id ?? null
|
||||||
|
const tenantId = session?.user?.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
const viewModeStorageKey = useMemo(() => {
|
||||||
|
const userKey = userId ?? (convexUserId ? String(convexUserId) : "anonymous")
|
||||||
|
return `tickets:view-mode:${tenantId}:${userKey}`
|
||||||
|
}, [tenantId, userId, convexUserId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem("tickets:view-mode")
|
const stored = localStorage.getItem(viewModeStorageKey)
|
||||||
if (stored === "table" || stored === "board") {
|
if (stored === "table" || stored === "board") {
|
||||||
setViewMode(stored)
|
setViewMode(stored)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}, [])
|
}, [viewModeStorageKey])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("tickets:view-mode", viewMode)
|
localStorage.setItem(viewModeStorageKey, viewMode)
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}, [viewMode])
|
}, [viewMode, viewModeStorageKey])
|
||||||
|
|
||||||
const { session, convexUserId, isStaff } = useAuth()
|
|
||||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
|
||||||
|
|
||||||
useDefaultQueues(tenantId)
|
useDefaultQueues(tenantId)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue