refactor: enhance user tables and machine ticket views
This commit is contained in:
parent
bd2f22d046
commit
28796bf105
7 changed files with 416 additions and 201 deletions
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
30
src/components/ui/empty-indicator.tsx
Normal file
30
src/components/ui/empty-indicator.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue