feat: cadastro manual de acesso remoto e ajustes de horas
This commit is contained in:
parent
8e3cbc7a9a
commit
f3a7045691
16 changed files with 1549 additions and 207 deletions
|
|
@ -38,6 +38,8 @@ const selectTriggerClass = "h-8 w-[140px] rounded-lg border border-slate-300 bg-
|
|||
const submitButtonClass =
|
||||
"inline-flex items-center gap-2 rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
|
||||
|
||||
type CommentsOrder = "descending" | "ascending"
|
||||
|
||||
export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||
const { convexUserId, isStaff, role } = useAuth()
|
||||
const normalizedRole = role ?? null
|
||||
|
|
@ -66,6 +68,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
const [editingComment, setEditingComment] = useState<{ id: string; value: string } | null>(null)
|
||||
const [savingCommentId, setSavingCommentId] = useState<string | null>(null)
|
||||
const [localBodies, setLocalBodies] = useState<Record<string, string>>({})
|
||||
const [commentsOrder, setCommentsOrder] = useState<CommentsOrder>("descending")
|
||||
|
||||
const templateArgs = convexUserId && isStaff
|
||||
? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users">, kind: "comment" as const }
|
||||
|
|
@ -133,8 +136,16 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
)
|
||||
|
||||
const commentsAll = useMemo(() => {
|
||||
return [...pending, ...ticket.comments]
|
||||
}, [pending, ticket.comments])
|
||||
const base = [...pending, ...ticket.comments]
|
||||
return base.sort((a, b) => {
|
||||
const aTime = new Date(a.createdAt).getTime()
|
||||
const bTime = new Date(b.createdAt).getTime()
|
||||
if (commentsOrder === "ascending") {
|
||||
return aTime - bTime
|
||||
}
|
||||
return bTime - aTime
|
||||
})
|
||||
}, [pending, ticket.comments, commentsOrder])
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
|
|
@ -232,6 +243,36 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 px-4 pb-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-neutral-600">
|
||||
<IconMessage className="size-4" />
|
||||
<span>{commentsAll.length} {commentsAll.length === 1 ? "comentário" : "comentários"}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 text-sm text-neutral-600 hover:text-neutral-900"
|
||||
onClick={() => setCommentsOrder((prev) => (prev === "descending" ? "ascending" : "descending"))}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="size-4"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
d="M7 18.5v-13M7 5.5L4 8.5M7 5.5l3 3M17 5.5v13M17 18.5l-3-3M17 18.5l3-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
{commentsOrder === "descending" ? "Mais recentes primeiro" : "Mais antigos primeiro"}
|
||||
</Button>
|
||||
</div>
|
||||
{commentsAll.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react"
|
||||
import { format, formatDistanceToNow } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { IconClock, IconDownload, IconPlayerPause, IconPlayerPlay, IconPencil } from "@tabler/icons-react"
|
||||
import { IconClock, IconDownload, IconPlayerPause, IconPlayerPlay, IconPencil, IconAdjustmentsHorizontal } from "@tabler/icons-react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
|
|
@ -21,6 +21,7 @@ import { CheckCircle2 } from "lucide-react"
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
|
|
@ -133,6 +134,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
const normalizedRole = (role ?? "").toLowerCase()
|
||||
const isManager = normalizedRole === "manager"
|
||||
const isAdmin = normalizedRole === "admin"
|
||||
const canAdjustWork = isAdmin || normalizedRole === "agent"
|
||||
const sessionName = session?.user?.name?.trim()
|
||||
const machineAssignedName = machineContext?.assignedUserName?.trim()
|
||||
const agentName =
|
||||
|
|
@ -163,6 +165,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
const updateSummary = useMutation(api.tickets.updateSummary)
|
||||
const startWork = useMutation(api.tickets.startWork)
|
||||
const pauseWork = useMutation(api.tickets.pauseWork)
|
||||
const adjustWorkSummaryMutation = useMutation(api.tickets.adjustWorkSummary)
|
||||
const updateCategories = useMutation(api.tickets.updateCategories)
|
||||
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
|
||||
const queuesEnabled = Boolean(isStaff && convexUserId)
|
||||
|
|
@ -248,6 +251,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
const [pausing, setPausing] = useState(false)
|
||||
const [exportingPdf, setExportingPdf] = useState(false)
|
||||
const [closeOpen, setCloseOpen] = useState(false)
|
||||
const [adjustDialogOpen, setAdjustDialogOpen] = useState(false)
|
||||
const [adjustInternalHours, setAdjustInternalHours] = useState("")
|
||||
const [adjustExternalHours, setAdjustExternalHours] = useState("")
|
||||
const [adjustReason, setAdjustReason] = useState("")
|
||||
const [adjusting, setAdjusting] = useState(false)
|
||||
const [assigneeChangeReason, setAssigneeChangeReason] = useState("")
|
||||
const [assigneeReasonError, setAssigneeReasonError] = useState<string | null>(null)
|
||||
const [companySelection, setCompanySelection] = useState<string>(NO_COMPANY_VALUE)
|
||||
|
|
@ -644,6 +652,25 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
])
|
||||
|
||||
const [workSummary, setWorkSummary] = useState<WorkSummarySnapshot | null>(initialWorkSummary)
|
||||
const formatHoursInput = useCallback((ms: number) => {
|
||||
if (!Number.isFinite(ms) || ms <= 0) {
|
||||
return "0"
|
||||
}
|
||||
const hours = ms / 3600000
|
||||
const rounded = Math.round(hours * 100) / 100
|
||||
return rounded.toString()
|
||||
}, [])
|
||||
|
||||
const effectiveWorkSummary = workSummary ?? initialWorkSummary
|
||||
|
||||
useEffect(() => {
|
||||
if (!adjustDialogOpen) return
|
||||
const internalMs = effectiveWorkSummary?.internalWorkedMs ?? 0
|
||||
const externalMs = effectiveWorkSummary?.externalWorkedMs ?? 0
|
||||
setAdjustInternalHours(formatHoursInput(internalMs))
|
||||
setAdjustExternalHours(formatHoursInput(externalMs))
|
||||
setAdjustReason("")
|
||||
}, [adjustDialogOpen, effectiveWorkSummary, formatHoursInput])
|
||||
const serverOffsetRef = useRef<number>(0)
|
||||
|
||||
const calibrateServerOffset = useCallback(
|
||||
|
|
@ -944,6 +971,116 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
}
|
||||
}
|
||||
|
||||
const handleAdjustSubmit = useCallback(
|
||||
async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (!convexUserId) {
|
||||
toast.error("Sessão expirada. Faça login novamente.")
|
||||
return
|
||||
}
|
||||
const parseHours = (value: string) => {
|
||||
const normalized = value.replace(",", ".").trim()
|
||||
if (normalized.length === 0) return 0
|
||||
const numeric = Number.parseFloat(normalized)
|
||||
if (!Number.isFinite(numeric) || numeric < 0) return null
|
||||
return numeric
|
||||
}
|
||||
const internalHoursParsed = parseHours(adjustInternalHours)
|
||||
if (internalHoursParsed === null) {
|
||||
toast.error("Informe um valor válido para horas internas.")
|
||||
return
|
||||
}
|
||||
const externalHoursParsed = parseHours(adjustExternalHours)
|
||||
if (externalHoursParsed === null) {
|
||||
toast.error("Informe um valor válido para horas externas.")
|
||||
return
|
||||
}
|
||||
const trimmedReason = adjustReason.trim()
|
||||
if (trimmedReason.length < 5) {
|
||||
toast.error("Descreva o motivo do ajuste (mínimo de 5 caracteres).")
|
||||
return
|
||||
}
|
||||
toast.dismiss("adjust-hours")
|
||||
toast.loading("Ajustando horas...", { id: "adjust-hours" })
|
||||
setAdjusting(true)
|
||||
try {
|
||||
const targetInternalMs = Math.round(internalHoursParsed * 3600000)
|
||||
const targetExternalMs = Math.round(externalHoursParsed * 3600000)
|
||||
const result = (await adjustWorkSummaryMutation({
|
||||
ticketId: ticket.id as Id<"tickets">,
|
||||
actorId: convexUserId as Id<"users">,
|
||||
internalWorkedMs: targetInternalMs,
|
||||
externalWorkedMs: targetExternalMs,
|
||||
reason: trimmedReason,
|
||||
})) as {
|
||||
ticketId: Id<"tickets">
|
||||
totalWorkedMs: number
|
||||
internalWorkedMs: number
|
||||
externalWorkedMs: number
|
||||
serverNow?: number
|
||||
perAgentTotals?: Array<{
|
||||
agentId: string
|
||||
agentName: string | null
|
||||
agentEmail: string | null
|
||||
avatarUrl: string | null
|
||||
totalWorkedMs: number
|
||||
internalWorkedMs: number
|
||||
externalWorkedMs: number
|
||||
}>
|
||||
}
|
||||
calibrateServerOffset(result?.serverNow ?? null)
|
||||
setWorkSummary((prev) => {
|
||||
const base: WorkSummarySnapshot =
|
||||
prev ??
|
||||
({
|
||||
ticketId: ticket.id as Id<"tickets">,
|
||||
totalWorkedMs: 0,
|
||||
internalWorkedMs: 0,
|
||||
externalWorkedMs: 0,
|
||||
serverNow: result?.serverNow ?? null,
|
||||
activeSession: null,
|
||||
perAgentTotals: [],
|
||||
} satisfies WorkSummarySnapshot)
|
||||
return {
|
||||
...base,
|
||||
totalWorkedMs: result?.totalWorkedMs ?? base.totalWorkedMs,
|
||||
internalWorkedMs: result?.internalWorkedMs ?? base.internalWorkedMs,
|
||||
externalWorkedMs: result?.externalWorkedMs ?? base.externalWorkedMs,
|
||||
serverNow: result?.serverNow ?? base.serverNow,
|
||||
perAgentTotals: result?.perAgentTotals
|
||||
? result.perAgentTotals.map((item) => ({
|
||||
agentId: item.agentId,
|
||||
agentName: item.agentName ?? null,
|
||||
agentEmail: item.agentEmail ?? null,
|
||||
avatarUrl: item.avatarUrl ?? null,
|
||||
totalWorkedMs: item.totalWorkedMs,
|
||||
internalWorkedMs: item.internalWorkedMs,
|
||||
externalWorkedMs: item.externalWorkedMs,
|
||||
}))
|
||||
: base.perAgentTotals,
|
||||
}
|
||||
})
|
||||
toast.success("Horas ajustadas com sucesso.", { id: "adjust-hours" })
|
||||
setAdjustDialogOpen(false)
|
||||
setAdjustReason("")
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Não foi possível ajustar as horas."
|
||||
toast.error(message, { id: "adjust-hours" })
|
||||
} finally {
|
||||
setAdjusting(false)
|
||||
}
|
||||
},
|
||||
[
|
||||
adjustInternalHours,
|
||||
adjustExternalHours,
|
||||
adjustReason,
|
||||
adjustWorkSummaryMutation,
|
||||
calibrateServerOffset,
|
||||
convexUserId,
|
||||
ticket.id,
|
||||
],
|
||||
)
|
||||
|
||||
const handleExportPdf = useCallback(async () => {
|
||||
try {
|
||||
setExportingPdf(true)
|
||||
|
|
@ -999,6 +1136,17 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{canAdjustWork && workSummary ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-semibold text-neutral-700 hover:bg-slate-50"
|
||||
onClick={() => setAdjustDialogOpen(true)}
|
||||
>
|
||||
<IconAdjustmentsHorizontal className="size-4" /> Ajustar horas
|
||||
</Button>
|
||||
) : null}
|
||||
{!editing ? (
|
||||
<Button
|
||||
size="icon"
|
||||
|
|
@ -1033,6 +1181,71 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
agentName={agentName}
|
||||
onSuccess={() => setStatus("RESOLVED")}
|
||||
/>
|
||||
<Dialog open={adjustDialogOpen} onOpenChange={setAdjustDialogOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<form onSubmit={handleAdjustSubmit} className="space-y-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ajustar horas do chamado</DialogTitle>
|
||||
<DialogDescription>
|
||||
Atualize os tempos registrados e descreva o motivo do ajuste. Apenas agentes e administradores visualizam este log.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="adjust-internal">Horas internas</Label>
|
||||
<Input
|
||||
id="adjust-internal"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.25}
|
||||
value={adjustInternalHours}
|
||||
onChange={(event) => setAdjustInternalHours(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="adjust-external">Horas externas</Label>
|
||||
<Input
|
||||
id="adjust-external"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.25}
|
||||
value={adjustExternalHours}
|
||||
onChange={(event) => setAdjustExternalHours(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="adjust-reason">Motivo do ajuste</Label>
|
||||
<Textarea
|
||||
id="adjust-reason"
|
||||
value={adjustReason}
|
||||
onChange={(event) => setAdjustReason(event.target.value)}
|
||||
placeholder="Descreva por que o tempo precisa ser ajustado..."
|
||||
rows={4}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setAdjustDialogOpen(false)
|
||||
setAdjusting(false)
|
||||
}}
|
||||
disabled={adjusting}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={adjusting}>
|
||||
{adjusting ? "Salvando..." : "Salvar ajuste"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { formatDistanceToNowStrict } from "date-fns"
|
||||
import { formatDistanceStrict } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { LayoutGrid } from "lucide-react"
|
||||
|
||||
|
|
@ -17,11 +18,72 @@ type TicketsBoardProps = {
|
|||
enteringIds?: Set<string>
|
||||
}
|
||||
|
||||
function formatUpdated(date: Date) {
|
||||
return formatDistanceToNowStrict(date, { addSuffix: true, locale: ptBR })
|
||||
const SECOND = 1_000
|
||||
const MINUTE = 60 * SECOND
|
||||
const HOUR = 60 * MINUTE
|
||||
const DAY = 24 * HOUR
|
||||
|
||||
function getTimestamp(value: Date | number | string | null | undefined) {
|
||||
if (value == null) return null
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : null
|
||||
}
|
||||
const parsed = value instanceof Date ? value.getTime() : new Date(value).getTime()
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function getNextDelay(diff: number) {
|
||||
if (diff < MINUTE) {
|
||||
return SECOND
|
||||
}
|
||||
|
||||
if (diff < HOUR) {
|
||||
const pastMinute = diff % MINUTE
|
||||
return pastMinute === 0 ? MINUTE : MINUTE - pastMinute
|
||||
}
|
||||
|
||||
if (diff < DAY) {
|
||||
const pastHour = diff % HOUR
|
||||
return pastHour === 0 ? HOUR : HOUR - pastHour
|
||||
}
|
||||
|
||||
const pastDay = diff % DAY
|
||||
return pastDay === 0 ? DAY : DAY - pastDay
|
||||
}
|
||||
|
||||
function formatUpdated(date: Date | number | string, now: number) {
|
||||
const timestamp = getTimestamp(date)
|
||||
if (timestamp === null) return "—"
|
||||
return formatDistanceStrict(timestamp, now, { addSuffix: true, locale: ptBR })
|
||||
}
|
||||
|
||||
export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
const ticketTimestamps = useMemo(() => {
|
||||
return tickets
|
||||
.map((ticket) => getTimestamp(ticket.updatedAt))
|
||||
.filter((value): value is number => value !== null)
|
||||
}, [tickets])
|
||||
|
||||
useEffect(() => {
|
||||
if (ticketTimestamps.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let minDelay = DAY
|
||||
for (const timestamp of ticketTimestamps) {
|
||||
const diff = Math.abs(now - timestamp)
|
||||
const candidate = Math.max(SECOND, getNextDelay(diff))
|
||||
if (candidate < minDelay) {
|
||||
minDelay = candidate
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => setNow(Date.now()), minDelay)
|
||||
return () => window.clearTimeout(timeoutId)
|
||||
}, [ticketTimestamps, now])
|
||||
|
||||
if (!tickets.length) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-12 text-center shadow-sm">
|
||||
|
|
@ -45,70 +107,70 @@ export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
|
|||
{tickets.map((ticket) => {
|
||||
const isEntering = enteringIds?.has(ticket.id)
|
||||
return (
|
||||
<Link
|
||||
key={ticket.id}
|
||||
href={`/tickets/${ticket.id}`}
|
||||
className={cn(
|
||||
"group block h-full rounded-3xl border border-slate-200 bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-300",
|
||||
isEntering ? "recent-ticket-enter" : ""
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-slate-200 bg-slate-100 px-3.5 py-1.5 text-xs font-semibold text-neutral-700"
|
||||
>
|
||||
#{ticket.reference}
|
||||
</Badge>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-semibold transition",
|
||||
getTicketStatusChipClass(ticket.status),
|
||||
)}
|
||||
>
|
||||
{getTicketStatusLabel(ticket.status)}
|
||||
<Link
|
||||
key={ticket.id}
|
||||
href={`/tickets/${ticket.id}`}
|
||||
className={cn(
|
||||
"group block h-full rounded-3xl border border-slate-200 bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-300",
|
||||
isEntering ? "recent-ticket-enter" : ""
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-slate-200 bg-slate-100 px-3.5 py-1.5 text-xs font-semibold text-neutral-700"
|
||||
>
|
||||
#{ticket.reference}
|
||||
</Badge>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-semibold transition",
|
||||
getTicketStatusChipClass(ticket.status),
|
||||
)}
|
||||
>
|
||||
{getTicketStatusLabel(ticket.status)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||
{formatUpdated(ticket.updatedAt, now)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||
{formatUpdated(ticket.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-5 line-clamp-2 text-lg font-semibold text-neutral-900">
|
||||
{ticket.subject || "Sem assunto"}
|
||||
</h3>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 text-sm text-neutral-600">
|
||||
<span className="font-medium text-neutral-500">Fila:</span>
|
||||
<span className="rounded-full bg-slate-100 px-3.5 py-0.5 text-xs font-medium text-neutral-700">
|
||||
{ticket.queue ?? "Sem fila"}
|
||||
</span>
|
||||
<span className="font-medium text-neutral-500">Prioridade:</span>
|
||||
<TicketPriorityPill
|
||||
priority={ticket.priority}
|
||||
className="h-7 gap-1.5 px-3.5 text-xs shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<dl className="mt-6 space-y-2.5 text-sm text-neutral-600">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<dt className="font-medium text-neutral-500">Empresa</dt>
|
||||
<dd className="truncate text-right text-neutral-700">
|
||||
{ticket.company?.name ?? "Sem empresa"}
|
||||
</dd>
|
||||
<h3 className="mt-5 line-clamp-2 text-lg font-semibold text-neutral-900">
|
||||
{ticket.subject || "Sem assunto"}
|
||||
</h3>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 text-sm text-neutral-600">
|
||||
<span className="font-medium text-neutral-500">Fila:</span>
|
||||
<span className="rounded-full bg-slate-100 px-3.5 py-0.5 text-xs font-medium text-neutral-700">
|
||||
{ticket.queue ?? "Sem fila"}
|
||||
</span>
|
||||
<span className="font-medium text-neutral-500">Prioridade:</span>
|
||||
<TicketPriorityPill
|
||||
priority={ticket.priority}
|
||||
className="h-7 gap-1.5 px-3.5 text-xs shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<dt className="font-medium text-neutral-500">Responsável</dt>
|
||||
<dd className="truncate text-right text-neutral-700">
|
||||
{ticket.assignee?.name ?? "Sem responsável"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<dt className="font-medium text-neutral-500">Solicitante</dt>
|
||||
<dd className="truncate text-right text-neutral-700">
|
||||
{ticket.requester?.name ?? ticket.requester?.email ?? "—"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Link>
|
||||
<dl className="mt-6 space-y-2.5 text-sm text-neutral-600">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<dt className="font-medium text-neutral-500">Empresa</dt>
|
||||
<dd className="truncate text-right text-neutral-700">
|
||||
{ticket.company?.name ?? "Sem empresa"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<dt className="font-medium text-neutral-500">Responsável</dt>
|
||||
<dd className="truncate text-right text-neutral-700">
|
||||
{ticket.assignee?.name ?? "Sem responsável"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<dt className="font-medium text-neutral-500">Solicitante</dt>
|
||||
<dd className="truncate text-right text-neutral-700">
|
||||
{ticket.requester?.name ?? ticket.requester?.email ?? "—"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue