refactor: enhance user tables and machine ticket views

This commit is contained in:
codex-bot 2025-11-03 11:51:53 -03:00
parent bd2f22d046
commit 28796bf105
7 changed files with 416 additions and 201 deletions

View file

@ -1452,7 +1452,11 @@ export function AdminMachinesOverview({ tenantId, initialCompanyFilterSlug = "al
<div className="max-h-64 overflow-auto rounded-md border border-slate-200"> <div className="max-h-64 overflow-auto rounded-md border border-slate-200">
<button <button
type="button" type="button"
onClick={() => { setCompanyFilterSlug("all"); setCompanySearch(""); setIsCompanyPopoverOpen(false) }} onClick={() => {
setCompanyFilterSlug("all")
setCompanySearch("")
setIsCompanyPopoverOpen(false)
}}
className="block w-full px-3 py-2 text-left text-sm hover:bg-slate-100" className="block w-full px-3 py-2 text-left text-sm hover:bg-slate-100"
> >
Todas empresas Todas empresas
@ -1463,7 +1467,11 @@ export function AdminMachinesOverview({ tenantId, initialCompanyFilterSlug = "al
<button <button
key={c.slug} key={c.slug}
type="button" type="button"
onClick={() => { setCompanyFilterSlug(c.slug); setCompanySearch(""); setIsCompanyPopoverOpen(false) }} onClick={() => {
setCompanyFilterSlug(c.slug)
setCompanySearch("")
setIsCompanyPopoverOpen(false)
}}
className="block w-full px-3 py-2 text-left text-sm hover:bg-slate-100" className="block w-full px-3 py-2 text-left text-sm hover:bg-slate-100"
> >
{c.name} {c.name}
@ -1477,7 +1485,19 @@ export function AdminMachinesOverview({ tenantId, initialCompanyFilterSlug = "al
<Checkbox checked={onlyAlerts} onCheckedChange={(v) => setOnlyAlerts(Boolean(v))} /> <Checkbox checked={onlyAlerts} onCheckedChange={(v) => setOnlyAlerts(Boolean(v))} />
<span>Somente com alertas</span> <span>Somente com alertas</span>
</label> </label>
<Button variant="outline" onClick={() => { setQ(""); setStatusFilter("all"); setCompanyFilterSlug("all"); setCompanySearch(""); setOnlyAlerts(false) }}>Limpar</Button> <Button
variant="outline"
onClick={() => {
setQ("")
setStatusFilter("all")
setCompanyFilterSlug("all")
setCompanySearch("")
setOnlyAlerts(false)
setIsCompanyPopoverOpen(false)
}}
>
Limpar
</Button>
<Button size="sm" variant="outline" className="gap-2" onClick={handleOpenExportDialog}> <Button size="sm" variant="outline" className="gap-2" onClick={handleOpenExportDialog}>
<Download className="size-4" /> <Download className="size-4" />
Exportar XLSX Exportar XLSX
@ -2610,70 +2630,66 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
))} ))}
</div> </div>
<div className="rounded-2xl border border-[color:var(--accent)] bg-[color:var(--accent)]/80 px-4 py-4"> <div className="rounded-2xl border border-[color:var(--accent)] bg-[color:var(--accent)]/80 px-4 py-4">
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-center"> <div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-2"> <div className="flex flex-col gap-1">
<div className="flex flex-wrap items-center justify-between gap-2"> <h4 className="text-sm font-semibold text-accent-foreground">Tickets abertos por esta máquina</h4>
<h4 className="text-sm font-semibold text-accent-foreground">Tickets abertos por esta máquina</h4>
{machineTicketsHref ? (
<Link
href={machineTicketsHref}
className="text-xs font-semibold text-accent-foreground underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent-foreground)] focus-visible:ring-offset-2"
>
Ver todos
</Link>
) : null}
</div>
{totalOpenTickets === 0 ? ( {totalOpenTickets === 0 ? (
<p className="text-xs text-[color:var(--accent-foreground)]/80"> <p className="text-xs text-[color:var(--accent-foreground)]/80">
Nenhum chamado em aberto registrado diretamente por esta máquina. Nenhum chamado em aberto registrado diretamente por esta máquina.
</p> </p>
) : hasAdditionalOpenTickets ? (
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--accent-foreground)]/70">
Mostrando últimos {Math.min(displayLimit, totalOpenTickets)} de {totalOpenTickets} chamados em aberto
</p>
) : ( ) : (
<div className="space-y-2"> <p className="text-xs text-[color:var(--accent-foreground)]/80">
{hasAdditionalOpenTickets ? ( Últimos chamados vinculados a esta máquina.
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--accent-foreground)]/70"> </p>
Mostrando últimos {Math.min(displayLimit, totalOpenTickets)} de {totalOpenTickets} chamados
em aberto
</p>
) : null}
<ul className="space-y-2">
{displayedMachineTickets.map((ticket) => {
const priorityMeta = getTicketPriorityMeta(ticket.priority)
return (
<li key={ticket.id}>
<Link
href={`/tickets/${ticket.id}`}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[color:var(--accent)] bg-white px-3 py-2 text-sm shadow-sm transition hover:-translate-y-0.5 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] focus-visible:ring-offset-2"
>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-neutral-900">
#{ticket.reference} · {ticket.subject}
</p>
<p className="text-xs text-neutral-500">
Atualizado {formatRelativeTime(new Date(ticket.updatedAt))}
</p>
</div>
<div className="flex items-center gap-2">
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold", priorityMeta.badgeClass)}>
{priorityMeta.label}
</Badge>
<TicketStatusBadge status={ticket.status} className="h-7 px-3 text-xs font-semibold" />
</div>
</Link>
</li>
)
})}
</ul>
</div>
)} )}
</div> </div>
<div className="self-center justify-self-end"> <div className="flex items-center gap-2">
<div className="flex h-12 min-w-[72px] items-center justify-center rounded-2xl border border-[color:var(--accent)] bg-white px-5 shadow-sm sm:min-w-[88px]"> <div className="flex h-10 min-w-[56px] items-center justify-center rounded-xl border border-[color:var(--accent)] bg-white px-3 text-[color:var(--accent-foreground)] shadow-sm">
<span className="text-2xl font-semibold leading-none text-accent-foreground tabular-nums sm:text-3xl"> <span className="text-lg font-semibold leading-none tabular-nums">{totalOpenTickets}</span>
{totalOpenTickets}
</span>
</div> </div>
{machineTicketsHref ? (
<Link
href={machineTicketsHref}
className="text-xs font-semibold text-accent-foreground underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent-foreground)] focus-visible:ring-offset-2"
>
Ver todos
</Link>
) : null}
</div> </div>
</div> </div>
{totalOpenTickets > 0 ? (
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{displayedMachineTickets.map((ticket) => {
const priorityMeta = getTicketPriorityMeta(ticket.priority)
return (
<Link
key={ticket.id}
href={`/tickets/${ticket.id}`}
className="group flex h-full flex-col justify-between gap-3 rounded-xl border border-[color:var(--accent)] bg-white p-3 text-sm shadow-sm transition hover:-translate-y-0.5 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] focus-visible:ring-offset-2"
>
<div className="space-y-1">
<p className="line-clamp-2 font-medium text-neutral-900">
#{ticket.reference} · {ticket.subject}
</p>
<p className="text-xs text-neutral-500">
Atualizado {formatRelativeTime(new Date(ticket.updatedAt))}
</p>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold", priorityMeta.badgeClass)}>
{priorityMeta.label}
</Badge>
<TicketStatusBadge status={ticket.status} className="h-7 px-3 text-xs font-semibold" />
</div>
</Link>
)
})}
</div>
) : null}
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{machine.authEmail ? ( {machine.authEmail ? (

View file

@ -5,6 +5,7 @@ import Link from "next/link"
import { usePaginatedQuery, useQuery } from "convex/react" import { usePaginatedQuery, useQuery } from "convex/react"
import { format, formatDistanceToNowStrict } from "date-fns" import { format, formatDistanceToNowStrict } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { IconUserOff } from "@tabler/icons-react"
import type { Id } from "@/convex/_generated/dataModel" import type { Id } from "@/convex/_generated/dataModel"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
@ -24,6 +25,7 @@ import { Spinner } from "@/components/ui/spinner"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty" import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"
import { TicketStatusBadge } from "@/components/tickets/status-badge" import { TicketStatusBadge } from "@/components/tickets/status-badge"
import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket" import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket"
import { EmptyIndicator } from "@/components/ui/empty-indicator"
type MachineTicketHistoryItem = { type MachineTicketHistoryItem = {
id: string id: string
@ -355,7 +357,6 @@ export function MachineTicketsHistoryClient({ tenantId: _tenantId, machineId }:
{tickets.map((ticket) => { {tickets.map((ticket) => {
const priorityMeta = getPriorityMeta(ticket.priority) const priorityMeta = getPriorityMeta(ticket.priority)
const requesterLabel = ticket.requester?.name ?? ticket.requester?.email ?? "Solicitante não informado" const requesterLabel = ticket.requester?.name ?? ticket.requester?.email ?? "Solicitante não informado"
const assigneeLabel = ticket.assignee?.name ?? ticket.assignee?.email ?? "Sem responsável"
const updatedLabel = formatRelativeTime(ticket.updatedAt) const updatedLabel = formatRelativeTime(ticket.updatedAt)
const updatedAbsolute = formatAbsoluteTime(ticket.updatedAt) const updatedAbsolute = formatAbsoluteTime(ticket.updatedAt)
return ( return (
@ -394,11 +395,21 @@ export function MachineTicketsHistoryClient({ tenantId: _tenantId, machineId }:
</div> </div>
</TableCell> </TableCell>
<TableCell className="align-top"> <TableCell className="align-top">
<div className="flex flex-col text-sm text-neutral-700"> <div className="flex flex-col items-start text-sm text-neutral-700">
<span>{assigneeLabel}</span> {ticket.assignee ? (
{ticket.assignee?.email ? ( <>
<span className="text-xs text-neutral-400">{ticket.assignee.email}</span> <span>{ticket.assignee.name ?? ticket.assignee.email ?? "—"}</span>
) : null} {ticket.assignee.email ? (
<span className="text-xs text-neutral-400">{ticket.assignee.email}</span>
) : null}
</>
) : (
<EmptyIndicator
icon={IconUserOff}
label="Sem responsável"
className="h-7 w-7 border-neutral-200 bg-transparent text-neutral-400"
/>
)}
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>

View file

@ -18,6 +18,8 @@ import {
IconTrash, IconTrash,
IconUserPlus, IconUserPlus,
IconUsers, IconUsers,
IconBuildingOff,
IconUserOff,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import { toast } from "sonner" import { toast } from "sonner"
@ -62,8 +64,8 @@ import {
} from "@/components/ui/table" } from "@/components/ui/table"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox" import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { EmptyIndicator } from "@/components/ui/empty-indicator"
export type AdminAccount = { export type AdminAccount = {
id: string id: string
@ -209,6 +211,15 @@ function AccountsTable({
const effectiveTenantId = tenantId || DEFAULT_TENANT_ID const effectiveTenantId = tenantId || DEFAULT_TENANT_ID
const headerCellClass =
"px-3 py-3 text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-4 last:pr-4"
const cellClass =
"px-3 py-4 text-sm text-neutral-700 first:pl-4 last:pr-4 whitespace-pre-wrap leading-snug"
const rowClass = "border-b border-border/60 text-sm transition-colors hover:bg-muted/40 last:border-b-0"
const metaLabelClass =
"text-[11px] font-semibold uppercase tracking-wide text-neutral-500"
const metaValueClass = "text-sm text-neutral-600 leading-tight break-words"
const filteredAccounts = useMemo(() => { const filteredAccounts = useMemo(() => {
const term = search.trim().toLowerCase() const term = search.trim().toLowerCase()
return accounts.filter((account) => { return accounts.filter((account) => {
@ -654,122 +665,192 @@ function AccountsTable({
</div> </div>
</div> </div>
<div className="overflow-x-auto"> <div className="w-full overflow-hidden rounded-2xl border border-border/60 bg-background">
<div className="min-w-[80rem] overflow-hidden rounded-lg border"> <Table className="w-full text-sm">
<Table className="w-full table-fixed text-sm"> <TableHeader className="bg-muted/60">
<TableHeader className="bg-muted"> <TableRow className="bg-transparent">
<TableHead className="w-12 px-3">
<Checkbox
checked={isVisibleIndeterminate ? "indeterminate" : allVisibleSelected}
onCheckedChange={(checked) => toggleVisibleSelection(checked === true)}
aria-label="Selecionar todos os usuários visíveis"
/>
</TableHead>
<TableHead className={cn(headerCellClass, "text-center min-w-[220px]")}>Usuário</TableHead>
<TableHead className={cn(headerCellClass, "hidden xl:table-cell text-center")}>Cargo</TableHead>
<TableHead className={cn(headerCellClass, "hidden lg:table-cell text-center")}>Gestor</TableHead>
<TableHead className={cn(headerCellClass, "hidden md:table-cell text-center")}>Empresa</TableHead>
<TableHead className={cn(headerCellClass, "text-center")}>Papel</TableHead>
<TableHead className={cn(headerCellClass, "text-center")}>Último acesso</TableHead>
<TableHead className={cn(headerCellClass, "text-center")}>Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAccounts.length === 0 ? (
<TableRow> <TableRow>
<TableHead className="w-12 px-4"> <TableCell colSpan={8} className={cn(cellClass, "text-center text-sm text-muted-foreground")}>
<Checkbox Nenhum usuário encontrado.
checked={isVisibleIndeterminate ? "indeterminate" : allVisibleSelected} </TableCell>
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">Cargo</TableHead>
<TableHead className="px-4">Gestor</TableHead>
<TableHead className="px-4">Empresa</TableHead>
<TableHead className="px-4">Papel</TableHead>
<TableHead className="px-4">Último acesso</TableHead>
<TableHead className="px-4 text-right">Ações</TableHead>
</TableRow> </TableRow>
</TableHeader> ) : (
<TableBody> filteredAccounts.map((account) => {
{filteredAccounts.length === 0 ? ( const initials = account.name
<TableRow> .split(" ")
<TableCell colSpan={8} className="px-6 py-6 text-center text-sm text-muted-foreground"> .filter(Boolean)
Nenhum usuário encontrado. .slice(0, 2)
</TableCell> .map((part) => part.charAt(0).toUpperCase())
</TableRow> .join("")
) : ( return (
filteredAccounts.map((account) => { <TableRow key={account.id} className={rowClass}>
const initials = account.name <TableCell className="w-12 px-3 py-4 align-middle">
.split(" ") <Checkbox
.filter(Boolean) checked={rowSelection[account.id] ?? false}
.slice(0, 2) onCheckedChange={(checked) =>
.map((part) => part.charAt(0).toUpperCase()) setRowSelection((prev) => ({ ...prev, [account.id]: checked === true }))
.join("") }
return ( aria-label={`Selecionar ${account.name}`}
<TableRow key={account.id} className="hover:bg-muted/40"> />
<TableCell className="w-12 px-4 py-3 align-middle"> </TableCell>
<Checkbox <TableCell className={cn(cellClass, "align-top text-neutral-900")}>
checked={rowSelection[account.id] ?? false} <div className="flex flex-col items-center gap-3 text-center sm:max-w-[28ch] sm:items-center sm:justify-center">
onCheckedChange={(checked) => <div className="min-w-0 space-y-1">
setRowSelection((prev) => ({ ...prev, [account.id]: checked === true })) <p className="text-base font-semibold leading-tight text-neutral-900 break-words">
} {account.name}
aria-label={`Selecionar ${account.name}`} </p>
/> <p className="text-xs text-neutral-500 break-words">{account.email}</p>
</TableCell> </div>
<TableCell className="px-4 py-3"> <div className="mt-1 grid gap-3 xl:hidden">
<div className="flex items-center gap-3"> <div className="flex flex-col gap-1">
<Avatar className="size-9 border border-border/60"> <span className={metaLabelClass}>Cargo</span>
<AvatarFallback>{initials || account.email.charAt(0).toUpperCase()}</AvatarFallback> <span className={cn(metaValueClass, "max-w-[28ch]")}>
</Avatar> {account.jobTitle ?? "Sem cargo"}
<div className="min-w-0 space-y-1"> </span>
<p className="font-semibold text-foreground">{account.name}</p> </div>
<p className="text-xs text-muted-foreground">{account.email}</p> <div className="flex flex-col gap-1">
<span className={metaLabelClass}>Empresa</span>
{account.companyName ? (
<span className={cn(metaValueClass, "max-w-[28ch]")}>{account.companyName}</span>
) : (
<span className={cn(metaValueClass, "flex justify-center")}>
<EmptyIndicator icon={IconBuildingOff} label="Nenhuma empresa vinculada" />
</span>
)}
</div>
<div className="flex flex-col gap-1">
<span className={metaLabelClass}>Gestor</span>
{account.managerName ? (
<div className={cn(metaValueClass, "max-w-[28ch] space-y-0.5")}>
<span className="block text-neutral-700">{account.managerName}</span>
{account.managerEmail ? (
<span className="block text-[11px] text-neutral-500 break-words">
{account.managerEmail}
</span>
) : null}
</div>
) : (
<span className={cn(metaValueClass, "flex justify-center")}>
<EmptyIndicator icon={IconUserOff} label="Nenhum gestor vinculado" />
</span>
)}
</div> </div>
</div> </div>
</TableCell> </div>
<TableCell className="px-4 py-3 text-sm text-muted-foreground"> </TableCell>
{account.jobTitle ? ( <TableCell
account.jobTitle className={cn(
) : ( cellClass,
<span className="italic text-muted-foreground/70">Sem cargo</span> "hidden xl:table-cell text-center text-xs text-neutral-600",
)} )}
</TableCell> >
<TableCell className="px-4 py-3 text-sm text-muted-foreground"> {account.jobTitle ? (
{account.managerName ? ( <span className="mx-auto block max-w-[28ch] break-words text-sm font-medium text-neutral-700 leading-snug">
<div className="flex flex-col"> {account.jobTitle}
<span>{account.managerName}</span> </span>
{account.managerEmail ? ( ) : (
<span className="text-xs text-muted-foreground">{account.managerEmail}</span> <span className="text-neutral-400">Sem cargo</span>
) : null} )}
</div> </TableCell>
) : ( <TableCell
<span className="italic text-muted-foreground/70">Sem gestor</span> className={cn(
)} cellClass,
</TableCell> "hidden lg:table-cell text-center text-xs text-neutral-600",
<TableCell className="px-4 py-3 text-sm text-muted-foreground"> )}
{account.companyName ?? <span className="italic text-muted-foreground/70">Sem empresa</span>} >
</TableCell> {account.managerName ? (
<TableCell className="px-4 py-3 text-sm"> <div className="mx-auto flex max-w-[28ch] flex-col items-center gap-1">
<Badge variant="secondary">{ROLE_LABEL[account.role]}</Badge> <span className="text-sm font-medium text-neutral-700 leading-tight">
</TableCell> {account.managerName}
<TableCell className="px-4 py-3 text-xs text-muted-foreground"> </span>
{formatDate(account.lastSeenAt)} {account.managerEmail ? (
</TableCell> <span className="text-[11px] text-neutral-500 break-words leading-tight">
<TableCell className="px-4 py-3 text-right align-middle"> {account.managerEmail}
<div className="flex justify-end gap-2"> </span>
<Button ) : null}
variant="outline"
size="sm"
disabled={!account.authUserId || isPending}
onClick={() => handleOpenEditor(account)}
>
<IconPencil className="mr-1 size-4" />
<span className="hidden sm:inline">Editar</span>
</Button>
<Button
variant="ghost"
size="sm"
className="text-rose-600 hover:bg-rose-50 hover:text-rose-700"
disabled={isPending}
onClick={() => openDeleteDialog([account.id])}
>
<IconTrash className="mr-1 size-4" />
<span className="hidden sm:inline">Remover</span>
</Button>
</div> </div>
</TableCell> ) : (
</TableRow> <span className="flex justify-center">
) <EmptyIndicator icon={IconUserOff} label="Nenhum gestor vinculado" />
}) </span>
)} )}
</TableBody> </TableCell>
</Table> <TableCell
</div> className={cn(
cellClass,
"hidden md:table-cell text-center text-xs text-neutral-600",
)}
>
{account.companyName ? (
<span className="mx-auto block max-w-[28ch] break-words text-sm text-neutral-700 leading-snug">
{account.companyName}
</span>
) : (
<span className="flex justify-center">
<EmptyIndicator icon={IconBuildingOff} label="Nenhuma empresa vinculada" />
</span>
)}
</TableCell>
<TableCell className={cn(cellClass, "align-middle text-center text-neutral-700")}>
<Badge variant="secondary" className="mx-auto bg-neutral-900 text-white hover:bg-neutral-900">
{ROLE_LABEL[account.role]}
</Badge>
</TableCell>
<TableCell
className={cn(
cellClass,
"align-middle text-center text-xs text-neutral-600",
)}
>
{formatDate(account.lastSeenAt)}
</TableCell>
<TableCell className={cn(cellClass, "align-middle text-center")}>
<div className="flex justify-center gap-2">
<Button
variant="outline"
size="icon"
disabled={!account.authUserId || isPending}
onClick={() => handleOpenEditor(account)}
title="Editar usuário"
>
<IconPencil className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-rose-600 hover:bg-rose-50 hover:text-rose-700"
disabled={isPending}
onClick={() => openDeleteDialog([account.id])}
title="Remover usuário"
>
<IconTrash className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</div> </div>
</CardContent> </CardContent>

View file

@ -5,6 +5,7 @@ import Link from "next/link"
import { formatDistanceStrict } from "date-fns" import { formatDistanceStrict } 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 { IconBuildingOff, IconCategory, IconUserOff } from "@tabler/icons-react"
import type { Ticket } from "@/lib/schemas/ticket" import type { Ticket } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
@ -12,6 +13,7 @@ import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { PrioritySelect } from "@/components/tickets/priority-select" import { PrioritySelect } from "@/components/tickets/priority-select"
import { getTicketStatusChipClass, getTicketStatusLabel } from "@/lib/ticket-status-style" import { getTicketStatusChipClass, getTicketStatusLabel } from "@/lib/ticket-status-style"
import { EmptyIndicator } from "@/components/ui/empty-indicator"
type TicketsBoardProps = { type TicketsBoardProps = {
tickets: Ticket[] tickets: Ticket[]
@ -185,7 +187,13 @@ export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
Empresa Empresa
</dt> </dt>
<dd className="text-neutral-700"> <dd className="text-neutral-700">
{ticket.company?.name ?? "Sem empresa"} {ticket.company?.name ?? (
<EmptyIndicator
icon={IconBuildingOff}
label="Nenhuma empresa vinculada"
className="h-7 w-7 border-neutral-200 bg-transparent text-neutral-400"
/>
)}
</dd> </dd>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@ -193,7 +201,13 @@ export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
Responsável Responsável
</dt> </dt>
<dd className="text-neutral-700"> <dd className="text-neutral-700">
{ticket.assignee?.name ?? "Sem responsável"} {ticket.assignee?.name ?? (
<EmptyIndicator
icon={IconUserOff}
label="Sem responsável"
className="h-7 w-7 border-neutral-200 bg-transparent text-neutral-400"
/>
)}
</dd> </dd>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@ -222,7 +236,15 @@ export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
<div className="mt-auto flex items-center justify-center border-t border-slate-200 pt-4 text-sm text-neutral-600 text-center"> <div className="mt-auto flex items-center justify-center border-t border-slate-200 pt-4 text-sm text-neutral-600 text-center">
<span className="text-neutral-700"> <span className="text-neutral-700">
Categoria:{" "} Categoria:{" "}
<span className="font-semibold text-neutral-900">{ticket.category?.name ?? "Sem categoria"}</span> {ticket.category?.name ? (
<span className="font-semibold text-neutral-900">{ticket.category.name}</span>
) : (
<EmptyIndicator
icon={IconCategory}
label="Sem categoria"
className="ml-2 h-7 w-7 border-neutral-200 bg-transparent text-neutral-400"
/>
)}
</span> </span>
</div> </div>
</div> </div>

View file

@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { format, formatDistanceToNowStrict } from "date-fns" import { format, formatDistanceToNowStrict } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { IconBuildingOff, IconCategory, IconHierarchyOff, IconUserOff } from "@tabler/icons-react"
import type { Ticket } from "@/lib/schemas/ticket" import type { Ticket } from "@/lib/schemas/ticket"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
@ -23,6 +24,7 @@ import { PrioritySelect } from "@/components/tickets/priority-select"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { deriveServerOffset, toServerTimestamp } from "@/components/tickets/ticket-timer.utils" import { deriveServerOffset, toServerTimestamp } from "@/components/tickets/ticket-timer.utils"
import { getTicketStatusLabel, getTicketStatusTextClass } from "@/lib/ticket-status-style" import { getTicketStatusLabel, getTicketStatusTextClass } from "@/lib/ticket-status-style"
import { EmptyIndicator } from "@/components/ui/empty-indicator"
const cellClass = const cellClass =
"px-3 py-4 sm:px-4 xl:px-3 xl:py-3 align-middle text-sm text-neutral-700 whitespace-normal " + "px-3 py-4 sm:px-4 xl:px-3 xl:py-3 align-middle text-sm text-neutral-700 whitespace-normal " +
@ -60,7 +62,15 @@ function formatQueueLabel(queue?: string | null) {
function AssigneeCell({ ticket }: { ticket: Ticket }) { function AssigneeCell({ ticket }: { ticket: Ticket }) {
if (!ticket.assignee) { if (!ticket.assignee) {
return <span className="text-sm text-neutral-600">Sem responsável</span> return (
<div className="flex items-center justify-center">
<EmptyIndicator
icon={IconUserOff}
label="Sem responsável"
className="h-10 w-10 border-neutral-200 bg-transparent text-neutral-400"
/>
</div>
)
} }
const initials = ticket.assignee.name const initials = ticket.assignee.name
@ -188,13 +198,19 @@ export function TicketsTable({ tickets, enteringIds }: TicketsTableProps) {
}} }}
> >
<TableCell className={`${cellClass} overflow-hidden`}> <TableCell className={`${cellClass} overflow-hidden`}>
<div className="flex min-w-0 flex-col gap-1"> <div className="flex min-w-0 flex-col items-start gap-1">
<span className="font-semibold tracking-tight text-neutral-900"> <span className="font-semibold tracking-tight text-neutral-900">#{ticket.reference}</span>
#{ticket.reference} <div className="flex items-center gap-1 text-xs text-neutral-500">
</span> {ticket.queue ? (
<span className="text-xs text-neutral-500"> <span className="truncate">{ticket.queue}</span>
{ticket.queue ?? "Sem fila"} ) : (
</span> <EmptyIndicator
icon={IconHierarchyOff}
label="Sem fila"
className="h-6 w-6 border-neutral-200 bg-transparent text-neutral-400"
/>
)}
</div>
</div> </div>
</TableCell> </TableCell>
<TableCell className={cellClass}> <TableCell className={cellClass}>
@ -220,7 +236,11 @@ export function TicketsTable({ tickets, enteringIds }: TicketsTableProps) {
) : null} ) : null}
</Badge> </Badge>
) : ( ) : (
<span className="text-neutral-400">Sem categoria</span> <EmptyIndicator
icon={IconCategory}
label="Sem categoria"
className="h-6 w-6 border-neutral-200 bg-transparent text-neutral-400"
/>
)} )}
</div> </div>
</div> </div>
@ -230,12 +250,17 @@ export function TicketsTable({ tickets, enteringIds }: TicketsTableProps) {
<span className="font-semibold text-neutral-800" title={ticket.requester.name}> <span className="font-semibold text-neutral-800" title={ticket.requester.name}>
{ticket.requester.name} {ticket.requester.name}
</span> </span>
<span {ticket.company?.name ? (
className="truncate text-sm text-neutral-600" <span className="truncate text-sm text-neutral-600" title={ticket.company.name}>
title={((ticket.company ?? null) as { name?: string } | null)?.name ?? "—"} {ticket.company.name}
> </span>
{((ticket.company ?? null) as { name?: string } | null)?.name ?? "—"} ) : (
</span> <EmptyIndicator
icon={IconBuildingOff}
label="Nenhuma empresa vinculada"
className="h-7 w-7 border-neutral-200 bg-transparent text-neutral-400"
/>
)}
</div> </div>
</TableCell> </TableCell>
<TableCell className={`${borderedCellClass} hidden md:table-cell text-center`}> <TableCell className={`${borderedCellClass} hidden md:table-cell text-center`}>
@ -252,12 +277,22 @@ export function TicketsTable({ tickets, enteringIds }: TicketsTableProps) {
</div> </div>
</TableCell> </TableCell>
<TableCell className={`${borderedCellClass} hidden lg:table-cell overflow-hidden text-center`}> <TableCell className={`${borderedCellClass} hidden lg:table-cell overflow-hidden text-center`}>
<span {ticket.queue ? (
className="mx-auto truncate text-sm font-semibold text-neutral-800" <span
title={queueDisplay.title} className="mx-auto truncate text-sm font-semibold text-neutral-800"
> title={queueDisplay.title}
{queueDisplay.label} >
</span> {queueDisplay.label}
</span>
) : (
<div className="flex justify-center">
<EmptyIndicator
icon={IconHierarchyOff}
label="Sem fila"
className="h-7 w-7 border-neutral-200 bg-transparent text-neutral-400"
/>
</div>
)}
</TableCell> </TableCell>
<TableCell className={`${borderedCellClass} overflow-hidden`}> <TableCell className={`${borderedCellClass} overflow-hidden`}>
<div className="flex min-w-0 flex-col items-center gap-1 text-center"> <div className="flex min-w-0 flex-col items-center gap-1 text-center">

View file

@ -0,0 +1,30 @@
"use client"
import type { ComponentType } from "react"
import { cn } from "@/lib/utils"
type IconProps = {
className?: string
}
type EmptyIndicatorProps = {
icon: ComponentType<IconProps>
label: string
className?: string
}
export function EmptyIndicator({ icon: Icon, label, className }: EmptyIndicatorProps) {
return (
<span
className={cn(
"relative inline-flex h-8 w-8 items-center justify-center rounded-full border border-dashed border-neutral-300 bg-neutral-50 text-neutral-400",
className,
)}
>
<Icon className="size-4" aria-hidden="true" />
<span className="sr-only">{label}</span>
</span>
)
}

View file

@ -1,3 +1,5 @@
import path from "node:path"
import { PrismaClient } from "@prisma/client" import { PrismaClient } from "@prisma/client"
declare global { declare global {
@ -5,14 +7,32 @@ declare global {
} }
// Resolve a robust DATABASE_URL for all runtimes (prod/dev) // Resolve a robust DATABASE_URL for all runtimes (prod/dev)
function resolveFileUrl(url: string) {
if (!url.startsWith("file:")) {
return url
}
const filePath = url.slice("file:".length)
if (filePath.startsWith("./") || filePath.startsWith("../")) {
const schemaDir = path.resolve(process.cwd(), "prisma")
const absolutePath = path.resolve(schemaDir, filePath)
return `file:${absolutePath}`
}
if (!filePath.startsWith("/")) {
const absolutePath = path.resolve(process.cwd(), filePath)
return `file:${absolutePath}`
}
return url
}
const resolvedDatabaseUrl = (() => { const resolvedDatabaseUrl = (() => {
const envUrl = process.env.DATABASE_URL?.trim() const envUrl = process.env.DATABASE_URL?.trim()
if (envUrl && envUrl.length > 0) return envUrl if (envUrl && envUrl.length > 0) return resolveFileUrl(envUrl)
// Fallbacks by environment to ensure correctness in containers // Fallbacks by environment to ensure correctness in containers
if (process.env.NODE_ENV === "production") { if (process.env.NODE_ENV === "production") {
return "file:/app/data/db.sqlite" return "file:/app/data/db.sqlite"
} }
return "file:./prisma/db.sqlite" return resolveFileUrl("file:./prisma/db.sqlite")
})() })()
export const prisma = export const prisma =