diff --git a/src/components/admin/machines/admin-machines-overview.tsx b/src/components/admin/machines/admin-machines-overview.tsx index 9bd4470..6f0b448 100644 --- a/src/components/admin/machines/admin-machines-overview.tsx +++ b/src/components/admin/machines/admin-machines-overview.tsx @@ -1452,7 +1452,11 @@ export function AdminMachinesOverview({ tenantId, initialCompanyFilterSlug = "al
+
-
-
-
-

Tickets abertos por esta máquina

- {machineTicketsHref ? ( - - Ver todos - - ) : null} -
+
+
+

Tickets abertos por esta máquina

{totalOpenTickets === 0 ? (

Nenhum chamado em aberto registrado diretamente por esta máquina.

+ ) : hasAdditionalOpenTickets ? ( +

+ Mostrando últimos {Math.min(displayLimit, totalOpenTickets)} de {totalOpenTickets} chamados em aberto +

) : ( -
- {hasAdditionalOpenTickets ? ( -

- Mostrando últimos {Math.min(displayLimit, totalOpenTickets)} de {totalOpenTickets} chamados - em aberto -

- ) : null} -
    - {displayedMachineTickets.map((ticket) => { - const priorityMeta = getTicketPriorityMeta(ticket.priority) - return ( -
  • - -
    -

    - #{ticket.reference} · {ticket.subject} -

    -

    - Atualizado {formatRelativeTime(new Date(ticket.updatedAt))} -

    -
    -
    - - {priorityMeta.label} - - -
    - -
  • - ) - })} -
-
+

+ Últimos chamados vinculados a esta máquina. +

)}
-
-
- - {totalOpenTickets} - +
+
+ {totalOpenTickets}
+ {machineTicketsHref ? ( + + Ver todos + + ) : null}
+ {totalOpenTickets > 0 ? ( +
+ {displayedMachineTickets.map((ticket) => { + const priorityMeta = getTicketPriorityMeta(ticket.priority) + return ( + +
+

+ #{ticket.reference} · {ticket.subject} +

+

+ Atualizado {formatRelativeTime(new Date(ticket.updatedAt))} +

+
+
+ + {priorityMeta.label} + + +
+ + ) + })} +
+ ) : null}
{machine.authEmail ? ( diff --git a/src/components/admin/machines/machine-tickets-history.client.tsx b/src/components/admin/machines/machine-tickets-history.client.tsx index 6af2e9f..0bcec6f 100644 --- a/src/components/admin/machines/machine-tickets-history.client.tsx +++ b/src/components/admin/machines/machine-tickets-history.client.tsx @@ -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 }:
-
- {assigneeLabel} - {ticket.assignee?.email ? ( - {ticket.assignee.email} - ) : null} +
+ {ticket.assignee ? ( + <> + {ticket.assignee.name ?? ticket.assignee.email ?? "—"} + {ticket.assignee.email ? ( + {ticket.assignee.email} + ) : null} + + ) : ( + + )}
diff --git a/src/components/admin/users/admin-users-workspace.tsx b/src/components/admin/users/admin-users-workspace.tsx index 42f9b56..e4c1050 100644 --- a/src/components/admin/users/admin-users-workspace.tsx +++ b/src/components/admin/users/admin-users-workspace.tsx @@ -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,122 +665,192 @@ function AccountsTable({
-
-
- - +
+
+ + + + toggleVisibleSelection(checked === true)} + aria-label="Selecionar todos os usuários visíveis" + /> + + Usuário + Cargo + Gestor + Empresa + Papel + Último acesso + Ações + + + + {filteredAccounts.length === 0 ? ( - - toggleVisibleSelection(checked === true)} - aria-label="Selecionar todos os usuários visíveis" - /> - - Usuário - Cargo - Gestor - Empresa - Papel - Último acesso - Ações + + Nenhum usuário encontrado. + - - - {filteredAccounts.length === 0 ? ( - - - Nenhum usuário encontrado. - - - ) : ( - filteredAccounts.map((account) => { - const initials = account.name - .split(" ") - .filter(Boolean) - .slice(0, 2) - .map((part) => part.charAt(0).toUpperCase()) - .join("") - return ( - - - - setRowSelection((prev) => ({ ...prev, [account.id]: checked === true })) - } - aria-label={`Selecionar ${account.name}`} - /> - - -
- - {initials || account.email.charAt(0).toUpperCase()} - -
-

{account.name}

-

{account.email}

+ ) : ( + filteredAccounts.map((account) => { + const initials = account.name + .split(" ") + .filter(Boolean) + .slice(0, 2) + .map((part) => part.charAt(0).toUpperCase()) + .join("") + return ( + + + + setRowSelection((prev) => ({ ...prev, [account.id]: checked === true })) + } + aria-label={`Selecionar ${account.name}`} + /> + + +
+
+

+ {account.name} +

+

{account.email}

+
+
+
+ Cargo + + {account.jobTitle ?? "Sem cargo"} + +
+
+ Empresa + {account.companyName ? ( + {account.companyName} + ) : ( + + + + )} +
+
+ Gestor + {account.managerName ? ( +
+ {account.managerName} + {account.managerEmail ? ( + + {account.managerEmail} + + ) : null} +
+ ) : ( + + + + )}
- - - {account.jobTitle ? ( - account.jobTitle - ) : ( - Sem cargo - )} - - - {account.managerName ? ( -
- {account.managerName} - {account.managerEmail ? ( - {account.managerEmail} - ) : null} -
- ) : ( - Sem gestor - )} -
- - {account.companyName ?? Sem empresa} - - - {ROLE_LABEL[account.role]} - - - {formatDate(account.lastSeenAt)} - - -
- - +
+
+ + {account.jobTitle ? ( + + {account.jobTitle} + + ) : ( + Sem cargo + )} + + + {account.managerName ? ( +
+ + {account.managerName} + + {account.managerEmail ? ( + + {account.managerEmail} + + ) : null}
-
- - ) - }) - )} - -
-
+ ) : ( + + + + )} + + + {account.companyName ? ( + + {account.companyName} + + ) : ( + + + + )} + + + + {ROLE_LABEL[account.role]} + + + + {formatDate(account.lastSeenAt)} + + +
+ + +
+
+ + ) + }) + )} + +
diff --git a/src/components/tickets/tickets-board.tsx b/src/components/tickets/tickets-board.tsx index 349954e..5ba36b7 100644 --- a/src/components/tickets/tickets-board.tsx +++ b/src/components/tickets/tickets-board.tsx @@ -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
- {ticket.company?.name ?? "Sem empresa"} + {ticket.company?.name ?? ( + + )}
@@ -193,7 +201,13 @@ export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) { Responsável
- {ticket.assignee?.name ?? "Sem responsável"} + {ticket.assignee?.name ?? ( + + )}
@@ -222,7 +236,15 @@ export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
Categoria:{" "} - {ticket.category?.name ?? "Sem categoria"} + {ticket.category?.name ? ( + {ticket.category.name} + ) : ( + + )}
diff --git a/src/components/tickets/tickets-table.tsx b/src/components/tickets/tickets-table.tsx index cd31309..ffbb17d 100644 --- a/src/components/tickets/tickets-table.tsx +++ b/src/components/tickets/tickets-table.tsx @@ -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 Sem responsável + return ( +
+ +
+ ) } const initials = ticket.assignee.name @@ -188,13 +198,19 @@ export function TicketsTable({ tickets, enteringIds }: TicketsTableProps) { }} > -
- - #{ticket.reference} - - - {ticket.queue ?? "Sem fila"} - +
+ #{ticket.reference} +
+ {ticket.queue ? ( + {ticket.queue} + ) : ( + + )} +
@@ -220,7 +236,11 @@ export function TicketsTable({ tickets, enteringIds }: TicketsTableProps) { ) : null} ) : ( - Sem categoria + )}
@@ -230,12 +250,17 @@ export function TicketsTable({ tickets, enteringIds }: TicketsTableProps) { {ticket.requester.name} - - {((ticket.company ?? null) as { name?: string } | null)?.name ?? "—"} - + {ticket.company?.name ? ( + + {ticket.company.name} + + ) : ( + + )}
diff --git a/src/components/ui/empty-indicator.tsx b/src/components/ui/empty-indicator.tsx new file mode 100644 index 0000000..88f2a54 --- /dev/null +++ b/src/components/ui/empty-indicator.tsx @@ -0,0 +1,30 @@ +"use client" + +import type { ComponentType } from "react" + +import { cn } from "@/lib/utils" + +type IconProps = { + className?: string +} + +type EmptyIndicatorProps = { + icon: ComponentType + label: string + className?: string +} + +export function EmptyIndicator({ icon: Icon, label, className }: EmptyIndicatorProps) { + return ( + + + ) +} + diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 0d4c72c..b25a7b9 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -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 =