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">
<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"
>
Todas empresas
@ -1463,7 +1467,11 @@ export function AdminMachinesOverview({ tenantId, initialCompanyFilterSlug = "al
<button
key={c.slug}
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"
>
{c.name}
@ -1477,7 +1485,19 @@ export function AdminMachinesOverview({ tenantId, initialCompanyFilterSlug = "al
<Checkbox checked={onlyAlerts} onCheckedChange={(v) => setOnlyAlerts(Boolean(v))} />
<span>Somente com alertas</span>
</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}>
<Download className="size-4" />
Exportar XLSX
@ -2610,10 +2630,27 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
))}
</div>
<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="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<h4 className="text-sm font-semibold text-accent-foreground">Tickets abertos por esta máquina</h4>
{totalOpenTickets === 0 ? (
<p className="text-xs text-[color:var(--accent-foreground)]/80">
Nenhum chamado em aberto registrado diretamente por esta máquina.
</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>
) : (
<p className="text-xs text-[color:var(--accent-foreground)]/80">
Últimos chamados vinculados a esta máquina.
</p>
)}
</div>
<div className="flex items-center gap-2">
<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-lg font-semibold leading-none tabular-nums">{totalOpenTickets}</span>
</div>
{machineTicketsHref ? (
<Link
href={machineTicketsHref}
@ -2623,57 +2660,36 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
</Link>
) : null}
</div>
{totalOpenTickets === 0 ? (
<p className="text-xs text-[color:var(--accent-foreground)]/80">
Nenhum chamado em aberto registrado diretamente por esta máquina.
</p>
) : (
<div className="space-y-2">
{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>
) : null}
<ul className="space-y-2">
</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 (
<li key={ticket.id}>
<Link
key={ticket.id}
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"
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="min-w-0 flex-1">
<p className="truncate font-medium text-neutral-900">
<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 items-center gap-2">
<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>
</li>
)
})}
</ul>
</div>
)}
</div>
<div className="self-center justify-self-end">
<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]">
<span className="text-2xl font-semibold leading-none text-accent-foreground tabular-nums sm:text-3xl">
{totalOpenTickets}
</span>
</div>
</div>
</div>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{machine.authEmail ? (

View file

@ -5,6 +5,7 @@ import Link from "next/link"
import { usePaginatedQuery, useQuery } from "convex/react"
import { format, formatDistanceToNowStrict } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconUserOff } from "@tabler/icons-react"
import type { Id } from "@/convex/_generated/dataModel"
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 { TicketStatusBadge } from "@/components/tickets/status-badge"
import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket"
import { EmptyIndicator } from "@/components/ui/empty-indicator"
type MachineTicketHistoryItem = {
id: string
@ -355,7 +357,6 @@ export function MachineTicketsHistoryClient({ tenantId: _tenantId, machineId }:
{tickets.map((ticket) => {
const priorityMeta = getPriorityMeta(ticket.priority)
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 updatedAbsolute = formatAbsoluteTime(ticket.updatedAt)
return (
@ -394,11 +395,21 @@ export function MachineTicketsHistoryClient({ tenantId: _tenantId, machineId }:
</div>
</TableCell>
<TableCell className="align-top">
<div className="flex flex-col text-sm text-neutral-700">
<span>{assigneeLabel}</span>
{ticket.assignee?.email ? (
<div className="flex flex-col items-start text-sm text-neutral-700">
{ticket.assignee ? (
<>
<span>{ticket.assignee.name ?? ticket.assignee.email ?? "—"}</span>
{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>
</TableCell>
</TableRow>

View file

@ -18,6 +18,8 @@ import {
IconTrash,
IconUserPlus,
IconUsers,
IconBuildingOff,
IconUserOff,
} from "@tabler/icons-react"
import { toast } from "sonner"
@ -62,8 +64,8 @@ import {
} from "@/components/ui/table"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { EmptyIndicator } from "@/components/ui/empty-indicator"
export type AdminAccount = {
id: string
@ -209,6 +211,15 @@ function AccountsTable({
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 term = search.trim().toLowerCase()
return accounts.filter((account) => {
@ -654,31 +665,30 @@ function AccountsTable({
</div>
</div>
<div className="overflow-x-auto">
<div className="min-w-[80rem] overflow-hidden rounded-lg border">
<Table className="w-full table-fixed text-sm">
<TableHeader className="bg-muted">
<TableRow>
<TableHead className="w-12 px-4">
<div className="w-full overflow-hidden rounded-2xl border border-border/60 bg-background">
<Table className="w-full text-sm">
<TableHeader className="bg-muted/60">
<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="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>
<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>
<TableCell colSpan={8} className="px-6 py-6 text-center text-sm text-muted-foreground">
<TableCell colSpan={8} className={cn(cellClass, "text-center text-sm text-muted-foreground")}>
Nenhum usuário encontrado.
</TableCell>
</TableRow>
@ -691,8 +701,8 @@ function AccountsTable({
.map((part) => part.charAt(0).toUpperCase())
.join("")
return (
<TableRow key={account.id} className="hover:bg-muted/40">
<TableCell className="w-12 px-4 py-3 align-middle">
<TableRow key={account.id} className={rowClass}>
<TableCell className="w-12 px-3 py-4 align-middle">
<Checkbox
checked={rowSelection[account.id] ?? false}
onCheckedChange={(checked) =>
@ -701,65 +711,137 @@ function AccountsTable({
aria-label={`Selecionar ${account.name}`}
/>
</TableCell>
<TableCell className="px-4 py-3">
<div className="flex items-center gap-3">
<Avatar className="size-9 border border-border/60">
<AvatarFallback>{initials || account.email.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<TableCell className={cn(cellClass, "align-top text-neutral-900")}>
<div className="flex flex-col items-center gap-3 text-center sm:max-w-[28ch] sm:items-center sm:justify-center">
<div className="min-w-0 space-y-1">
<p className="font-semibold text-foreground">{account.name}</p>
<p className="text-xs text-muted-foreground">{account.email}</p>
<p className="text-base font-semibold leading-tight text-neutral-900 break-words">
{account.name}
</p>
<p className="text-xs text-neutral-500 break-words">{account.email}</p>
</div>
<div className="mt-1 grid gap-3 xl:hidden">
<div className="flex flex-col gap-1">
<span className={metaLabelClass}>Cargo</span>
<span className={cn(metaValueClass, "max-w-[28ch]")}>
{account.jobTitle ?? "Sem cargo"}
</span>
</div>
</TableCell>
<TableCell className="px-4 py-3 text-sm text-muted-foreground">
{account.jobTitle ? (
account.jobTitle
<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="italic text-muted-foreground/70">Sem cargo</span>
<span className={cn(metaValueClass, "flex justify-center")}>
<EmptyIndicator icon={IconBuildingOff} label="Nenhuma empresa vinculada" />
</span>
)}
</TableCell>
<TableCell className="px-4 py-3 text-sm text-muted-foreground">
</div>
<div className="flex flex-col gap-1">
<span className={metaLabelClass}>Gestor</span>
{account.managerName ? (
<div className="flex flex-col">
<span>{account.managerName}</span>
<div className={cn(metaValueClass, "max-w-[28ch] space-y-0.5")}>
<span className="block text-neutral-700">{account.managerName}</span>
{account.managerEmail ? (
<span className="text-xs text-muted-foreground">{account.managerEmail}</span>
<span className="block text-[11px] text-neutral-500 break-words">
{account.managerEmail}
</span>
) : null}
</div>
) : (
<span className="italic text-muted-foreground/70">Sem gestor</span>
<span className={cn(metaValueClass, "flex justify-center")}>
<EmptyIndicator icon={IconUserOff} label="Nenhum gestor vinculado" />
</span>
)}
</div>
</div>
</div>
</TableCell>
<TableCell
className={cn(
cellClass,
"hidden xl:table-cell text-center text-xs text-neutral-600",
)}
>
{account.jobTitle ? (
<span className="mx-auto block max-w-[28ch] break-words text-sm font-medium text-neutral-700 leading-snug">
{account.jobTitle}
</span>
) : (
<span className="text-neutral-400">Sem cargo</span>
)}
</TableCell>
<TableCell className="px-4 py-3 text-sm text-muted-foreground">
{account.companyName ?? <span className="italic text-muted-foreground/70">Sem empresa</span>}
<TableCell
className={cn(
cellClass,
"hidden lg:table-cell text-center text-xs text-neutral-600",
)}
>
{account.managerName ? (
<div className="mx-auto flex max-w-[28ch] flex-col items-center gap-1">
<span className="text-sm font-medium text-neutral-700 leading-tight">
{account.managerName}
</span>
{account.managerEmail ? (
<span className="text-[11px] text-neutral-500 break-words leading-tight">
{account.managerEmail}
</span>
) : null}
</div>
) : (
<span className="flex justify-center">
<EmptyIndicator icon={IconUserOff} label="Nenhum gestor vinculado" />
</span>
)}
</TableCell>
<TableCell className="px-4 py-3 text-sm">
<Badge variant="secondary">{ROLE_LABEL[account.role]}</Badge>
<TableCell
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="px-4 py-3 text-xs text-muted-foreground">
<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="px-4 py-3 text-right align-middle">
<div className="flex justify-end gap-2">
<TableCell className={cn(cellClass, "align-middle text-center")}>
<div className="flex justify-center gap-2">
<Button
variant="outline"
size="sm"
size="icon"
disabled={!account.authUserId || isPending}
onClick={() => handleOpenEditor(account)}
title="Editar usuário"
>
<IconPencil className="mr-1 size-4" />
<span className="hidden sm:inline">Editar</span>
<IconPencil className="size-4" />
</Button>
<Button
variant="ghost"
size="sm"
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="mr-1 size-4" />
<span className="hidden sm:inline">Remover</span>
<IconTrash className="size-4" />
</Button>
</div>
</TableCell>
@ -770,7 +852,6 @@ function AccountsTable({
</TableBody>
</Table>
</div>
</div>
</CardContent>
<Dialog open={deleteDialogIds.length > 0} onOpenChange={(open) => (!open ? closeDeleteDialog() : null)}>

View file

@ -5,6 +5,7 @@ import Link from "next/link"
import { formatDistanceStrict } from "date-fns"
import { ptBR } from "date-fns/locale"
import { LayoutGrid } from "lucide-react"
import { IconBuildingOff, IconCategory, IconUserOff } from "@tabler/icons-react"
import type { Ticket } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
@ -12,6 +13,7 @@ import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "
import { cn } from "@/lib/utils"
import { PrioritySelect } from "@/components/tickets/priority-select"
import { getTicketStatusChipClass, getTicketStatusLabel } from "@/lib/ticket-status-style"
import { EmptyIndicator } from "@/components/ui/empty-indicator"
type TicketsBoardProps = {
tickets: Ticket[]
@ -185,7 +187,13 @@ export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
Empresa
</dt>
<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>
</div>
<div className="flex flex-col gap-1">
@ -193,7 +201,13 @@ export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
Responsável
</dt>
<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>
</div>
<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">
<span className="text-neutral-700">
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>
</div>
</div>

View file

@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"
import { useRouter } from "next/navigation"
import { format, formatDistanceToNowStrict } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconBuildingOff, IconCategory, IconHierarchyOff, IconUserOff } from "@tabler/icons-react"
import type { Ticket } from "@/lib/schemas/ticket"
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 { deriveServerOffset, toServerTimestamp } from "@/components/tickets/ticket-timer.utils"
import { getTicketStatusLabel, getTicketStatusTextClass } from "@/lib/ticket-status-style"
import { EmptyIndicator } from "@/components/ui/empty-indicator"
const cellClass =
"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 }) {
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
@ -188,13 +198,19 @@ export function TicketsTable({ tickets, enteringIds }: TicketsTableProps) {
}}
>
<TableCell className={`${cellClass} overflow-hidden`}>
<div className="flex min-w-0 flex-col gap-1">
<span className="font-semibold tracking-tight text-neutral-900">
#{ticket.reference}
</span>
<span className="text-xs text-neutral-500">
{ticket.queue ?? "Sem fila"}
</span>
<div className="flex min-w-0 flex-col items-start gap-1">
<span className="font-semibold tracking-tight text-neutral-900">#{ticket.reference}</span>
<div className="flex items-center gap-1 text-xs text-neutral-500">
{ticket.queue ? (
<span className="truncate">{ticket.queue}</span>
) : (
<EmptyIndicator
icon={IconHierarchyOff}
label="Sem fila"
className="h-6 w-6 border-neutral-200 bg-transparent text-neutral-400"
/>
)}
</div>
</div>
</TableCell>
<TableCell className={cellClass}>
@ -220,7 +236,11 @@ export function TicketsTable({ tickets, enteringIds }: TicketsTableProps) {
) : null}
</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>
@ -230,12 +250,17 @@ export function TicketsTable({ tickets, enteringIds }: TicketsTableProps) {
<span className="font-semibold text-neutral-800" title={ticket.requester.name}>
{ticket.requester.name}
</span>
<span
className="truncate text-sm text-neutral-600"
title={((ticket.company ?? null) as { name?: string } | null)?.name ?? "—"}
>
{((ticket.company ?? null) as { name?: string } | null)?.name ?? "—"}
{ticket.company?.name ? (
<span className="truncate text-sm text-neutral-600" title={ticket.company.name}>
{ticket.company.name}
</span>
) : (
<EmptyIndicator
icon={IconBuildingOff}
label="Nenhuma empresa vinculada"
className="h-7 w-7 border-neutral-200 bg-transparent text-neutral-400"
/>
)}
</div>
</TableCell>
<TableCell className={`${borderedCellClass} hidden md:table-cell text-center`}>
@ -252,12 +277,22 @@ export function TicketsTable({ tickets, enteringIds }: TicketsTableProps) {
</div>
</TableCell>
<TableCell className={`${borderedCellClass} hidden lg:table-cell overflow-hidden text-center`}>
{ticket.queue ? (
<span
className="mx-auto truncate text-sm font-semibold text-neutral-800"
title={queueDisplay.title}
>
{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 className={`${borderedCellClass} overflow-hidden`}>
<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"
declare global {
@ -5,14 +7,32 @@ declare global {
}
// 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 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
if (process.env.NODE_ENV === "production") {
return "file:/app/data/db.sqlite"
}
return "file:./prisma/db.sqlite"
return resolveFileUrl("file:./prisma/db.sqlite")
})()
export const prisma =