feat: surface ticket work metrics and refresh list layout
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
parent
744d5933d4
commit
55511f3a0e
20 changed files with 1102 additions and 357 deletions
|
|
@ -38,8 +38,12 @@ export function DeleteTicketDialog({ ticketId }: { ticketId: Id<"tickets"> }) {
|
|||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-2 text-destructive hover:bg-destructive/10">
|
||||
<Trash2 className="size-4" /> Excluir
|
||||
<Button
|
||||
size="icon"
|
||||
aria-label="Excluir ticket"
|
||||
className="border border-[#fca5a5] bg-[#fecaca] text-[#7f1d1d] shadow-sm transition hover:bg-[#fca5a5] focus-visible:ring-[#fca5a5]/30"
|
||||
>
|
||||
<Trash2 className="size-4 text-current" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { z } from "zod"
|
||||
import { useState } from "react"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import type { TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||
import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
// @ts-ignore
|
||||
import { api } from "@/convex/_generated/api"
|
||||
|
|
@ -20,6 +20,15 @@ import { toast } from "sonner"
|
|||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Dropzone } from "@/components/ui/dropzone"
|
||||
import { RichTextEditor } from "@/components/ui/rich-text-editor"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
PriorityIcon,
|
||||
priorityBadgeClass,
|
||||
priorityItemClass,
|
||||
priorityStyles,
|
||||
priorityTriggerClass,
|
||||
} from "@/components/tickets/priority-select"
|
||||
|
||||
const schema = z.object({
|
||||
subject: z.string().min(3, "Informe um assunto"),
|
||||
|
|
@ -43,6 +52,11 @@ export function NewTicketDialog() {
|
|||
const create = useMutation(api.tickets.create)
|
||||
const addComment = useMutation(api.tickets.addComment)
|
||||
const [attachments, setAttachments] = useState<Array<{ storageId: string; name: string; size?: number; type?: string }>>([])
|
||||
const priorityValue = form.watch("priority") as TicketPriority
|
||||
const channelValue = form.watch("channel")
|
||||
const queueValue = form.watch("queueName") ?? "NONE"
|
||||
const selectTriggerClass = "flex h-8 w-full items-center justify-between rounded-full border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
|
||||
const selectItemClass = "flex items-center gap-2 rounded-md px-2 py-2 text-sm text-neutral-800 transition data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10"
|
||||
|
||||
async function submit(values: z.infer<typeof schema>) {
|
||||
if (!userId) return
|
||||
|
|
@ -91,7 +105,12 @@ export function NewTicketDialog() {
|
|||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">Novo ticket</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
|
||||
>
|
||||
Novo ticket
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
|
@ -126,53 +145,90 @@ export function NewTicketDialog() {
|
|||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Field>
|
||||
<FieldLabel>Prioridade</FieldLabel>
|
||||
<Select value={form.watch("priority")} onValueChange={(v) => form.setValue("priority", v as z.infer<typeof schema>["priority"])}>
|
||||
<SelectTrigger><SelectValue placeholder="Prioridade" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="LOW">Baixa</SelectItem>
|
||||
<SelectItem value="MEDIUM">Média</SelectItem>
|
||||
<SelectItem value="HIGH">Alta</SelectItem>
|
||||
<SelectItem value="URGENT">Urgente</SelectItem>
|
||||
<Select value={priorityValue} onValueChange={(v) => form.setValue("priority", v as z.infer<typeof schema>["priority"])}>
|
||||
<SelectTrigger className={cn(priorityTriggerClass, "w-full justify-between")}>
|
||||
<SelectValue>
|
||||
<Badge className={cn(priorityBadgeClass, priorityStyles[priorityValue]?.badgeClass)}>
|
||||
<PriorityIcon value={priorityValue} />
|
||||
{priorityStyles[priorityValue]?.label ?? priorityValue}
|
||||
</Badge>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
|
||||
{(["LOW", "MEDIUM", "HIGH", "URGENT"] as const).map((option) => (
|
||||
<SelectItem key={option} value={option} className={priorityItemClass}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<PriorityIcon value={option} />
|
||||
{priorityStyles[option].label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Canal</FieldLabel>
|
||||
<Select value={form.watch("channel")} onValueChange={(v) => form.setValue("channel", v as z.infer<typeof schema>["channel"])}>
|
||||
<SelectTrigger><SelectValue placeholder="Canal" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="EMAIL">E-mail</SelectItem>
|
||||
<SelectItem value="WHATSAPP">WhatsApp</SelectItem>
|
||||
<SelectItem value="CHAT">Chat</SelectItem>
|
||||
<SelectItem value="PHONE">Telefone</SelectItem>
|
||||
<SelectItem value="API">API</SelectItem>
|
||||
<SelectItem value="MANUAL">Manual</SelectItem>
|
||||
<Select value={channelValue} onValueChange={(v) => form.setValue("channel", v as z.infer<typeof schema>["channel"])}>
|
||||
<SelectTrigger className={selectTriggerClass}>
|
||||
<SelectValue placeholder="Canal" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
|
||||
<SelectItem value="EMAIL" className={selectItemClass}>
|
||||
E-mail
|
||||
</SelectItem>
|
||||
<SelectItem value="WHATSAPP" className={selectItemClass}>
|
||||
WhatsApp
|
||||
</SelectItem>
|
||||
<SelectItem value="CHAT" className={selectItemClass}>
|
||||
Chat
|
||||
</SelectItem>
|
||||
<SelectItem value="PHONE" className={selectItemClass}>
|
||||
Telefone
|
||||
</SelectItem>
|
||||
<SelectItem value="API" className={selectItemClass}>
|
||||
API
|
||||
</SelectItem>
|
||||
<SelectItem value="MANUAL" className={selectItemClass}>
|
||||
Manual
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Fila</FieldLabel>
|
||||
<Select value={queueValue} onValueChange={(v) => form.setValue("queueName", v === "NONE" ? null : v)}>
|
||||
<SelectTrigger className={selectTriggerClass}>
|
||||
<SelectValue placeholder="Sem fila" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
|
||||
<SelectItem value="NONE" className={selectItemClass}>
|
||||
Sem fila
|
||||
</SelectItem>
|
||||
{queues.map((q) => (
|
||||
<SelectItem key={q.id} value={q.name} className={selectItemClass}>
|
||||
{q.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Fila</FieldLabel>
|
||||
{(() => {
|
||||
const NONE = "NONE";
|
||||
const current = form.watch("queueName") ?? NONE;
|
||||
return (
|
||||
<Select value={current} onValueChange={(v) => form.setValue("queueName", v === NONE ? null : v)}>
|
||||
<SelectTrigger><SelectValue placeholder="Sem fila" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>Sem fila</SelectItem>
|
||||
{queues.map((q) => (
|
||||
<SelectItem key={q.id} value={q.name}>{q.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
})()}
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={loading}>{loading ? (<><Spinner className="me-2" /> Criando…</>) : "Criar"}</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded-lg border border-black bg-black px-4 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 disabled:opacity-60"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Spinner className="me-2" /> Criando…
|
||||
</>
|
||||
) : (
|
||||
"Criar"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,15 @@
|
|||
import { type TicketPriority } from "@/lib/schemas/ticket"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PriorityIcon, priorityStyles } from "@/components/tickets/priority-select"
|
||||
|
||||
const priorityStyles: Record<TicketPriority, { label: string; className: string }> = {
|
||||
LOW: { label: "Baixa", className: "border border-slate-200 bg-slate-100 text-slate-700" },
|
||||
MEDIUM: { label: "Média", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
|
||||
HIGH: { label: "Alta", className: "border border-slate-200 bg-[#fde8d1] text-[#7d3b05]" },
|
||||
URGENT: { label: "Urgente", className: "border border-slate-200 bg-[#fbd9dd] text-[#8b0f1c]" },
|
||||
}
|
||||
|
||||
const baseClass = "inline-flex items-center rounded-full px-3 py-0.5 text-xs font-semibold"
|
||||
const baseClass = "inline-flex items-center gap-1.5 rounded-full px-3 py-0.5 text-xs font-semibold"
|
||||
|
||||
export function TicketPriorityPill({ priority }: { priority: TicketPriority }) {
|
||||
const styles = priorityStyles[priority]
|
||||
return (
|
||||
<Badge className={cn(baseClass, styles?.className)}>
|
||||
<Badge className={cn(baseClass, styles?.badgeClass)}>
|
||||
<PriorityIcon value={priority} />
|
||||
{styles?.label ?? priority}
|
||||
</Badge>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,19 +13,19 @@ import { toast } from "sonner"
|
|||
import { ArrowDown, ArrowRight, ArrowUp, ChevronsUp } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const priorityStyles: Record<TicketPriority, { label: string; badgeClass: string }> = {
|
||||
export const priorityStyles: Record<TicketPriority, { label: string; badgeClass: string }> = {
|
||||
LOW: { label: "Baixa", badgeClass: "border border-slate-200 bg-slate-100 text-slate-700" },
|
||||
MEDIUM: { label: "Média", badgeClass: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
|
||||
HIGH: { label: "Alta", badgeClass: "border border-slate-200 bg-[#fde8d1] text-[#7d3b05]" },
|
||||
URGENT: { label: "Urgente", badgeClass: "border border-slate-200 bg-[#fbd9dd] text-[#8b0f1c]" },
|
||||
}
|
||||
|
||||
const triggerClass = "h-8 w-[160px] rounded-full border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
|
||||
const itemClass = "flex items-center gap-2 rounded-md px-2 py-2 text-sm text-neutral-800 transition data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10"
|
||||
export const priorityTriggerClass = "h-8 w-[160px] rounded-full border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
|
||||
export const priorityItemClass = "flex items-center gap-2 rounded-md px-2 py-2 text-sm text-neutral-800 transition data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10"
|
||||
const iconClass = "size-4 text-neutral-700"
|
||||
const baseBadgeClass = "inline-flex items-center gap-2 rounded-full px-3 py-0.5 text-xs font-semibold"
|
||||
export const priorityBadgeClass = "inline-flex items-center gap-2 rounded-full px-3 py-0.5 text-xs font-semibold"
|
||||
|
||||
function PriorityIcon({ value }: { value: TicketPriority }) {
|
||||
export function PriorityIcon({ value }: { value: TicketPriority }) {
|
||||
if (value === "LOW") return <ArrowDown className={iconClass} />
|
||||
if (value === "MEDIUM") return <ArrowRight className={iconClass} />
|
||||
if (value === "HIGH") return <ArrowUp className={iconClass} />
|
||||
|
|
@ -55,9 +55,9 @@ export function PrioritySelect({ ticketId, value }: { ticketId: string; value: T
|
|||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={triggerClass}>
|
||||
<SelectTrigger className={priorityTriggerClass}>
|
||||
<SelectValue>
|
||||
<Badge className={cn(baseBadgeClass, priorityStyles[priority]?.badgeClass)}>
|
||||
<Badge className={cn(priorityBadgeClass, priorityStyles[priority]?.badgeClass)}>
|
||||
<PriorityIcon value={priority} />
|
||||
{priorityStyles[priority]?.label ?? priority}
|
||||
</Badge>
|
||||
|
|
@ -65,7 +65,7 @@ export function PrioritySelect({ ticketId, value }: { ticketId: string; value: T
|
|||
</SelectTrigger>
|
||||
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
||||
{(["LOW", "MEDIUM", "HIGH", "URGENT"] as const).map((option) => (
|
||||
<SelectItem key={option} value={option} className={itemClass}>
|
||||
<SelectItem key={option} value={option} className={priorityItemClass}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<PriorityIcon value={option} />
|
||||
{priorityStyles[option].label}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useMemo, useState } from "react"
|
|||
import { formatDistanceToNow } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { IconLock, IconMessage } from "@tabler/icons-react"
|
||||
import { Download, FileIcon } from "lucide-react"
|
||||
import { FileIcon, Trash2, X } from "lucide-react"
|
||||
import { useMutation } from "convex/react"
|
||||
// @ts-ignore
|
||||
import { api } from "@/convex/_generated/api"
|
||||
|
|
@ -18,7 +18,7 @@ import { Button } from "@/components/ui/button"
|
|||
import { toast } from "sonner"
|
||||
import { Dropzone } from "@/components/ui/dropzone"
|
||||
import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
|
||||
|
|
@ -28,16 +28,20 @@ interface TicketCommentsProps {
|
|||
|
||||
const badgeInternal = "gap-1 rounded-full border border-slate-300 bg-neutral-900 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-white"
|
||||
const selectTriggerClass = "h-8 w-[140px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
|
||||
const submitButtonClass = "inline-flex items-center gap-2 rounded-lg border border-black bg-[#00e8ff] px-3 py-2 text-sm font-semibold text-black transition hover:bg-[#00d6eb]"
|
||||
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"
|
||||
|
||||
export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||
const { userId } = useAuth()
|
||||
const addComment = useMutation(api.tickets.addComment)
|
||||
const removeAttachment = useMutation(api.tickets.removeCommentAttachment)
|
||||
const [body, setBody] = useState("")
|
||||
const [attachmentsToSend, setAttachmentsToSend] = useState<Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>>([])
|
||||
const [preview, setPreview] = useState<string | null>(null)
|
||||
const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id" | "author" | "visibility" | "body" | "attachments" | "createdAt" | "updatedAt">[]>([])
|
||||
const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("PUBLIC")
|
||||
const [attachmentToRemove, setAttachmentToRemove] = useState<{ commentId: string; attachmentId: string; name: string } | null>(null)
|
||||
const [removingAttachment, setRemovingAttachment] = useState(false)
|
||||
|
||||
const commentsAll = useMemo(() => {
|
||||
return [...pending, ...ticket.comments]
|
||||
|
|
@ -47,7 +51,10 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
event.preventDefault()
|
||||
if (!userId) return
|
||||
const now = new Date()
|
||||
const attachments = attachmentsToSend
|
||||
const attachments = attachmentsToSend.map((item) => ({ ...item }))
|
||||
const previewsToRevoke = attachments
|
||||
.map((attachment) => attachment.previewUrl)
|
||||
.filter((previewUrl): previewUrl is string => Boolean(previewUrl && previewUrl.startsWith("blob:")))
|
||||
const optimistic = {
|
||||
id: `temp-${now.getTime()}`,
|
||||
author: ticket.requester,
|
||||
|
|
@ -56,6 +63,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
attachments: attachments.map((attachment) => ({
|
||||
id: attachment.storageId,
|
||||
name: attachment.name,
|
||||
type: attachment.type,
|
||||
url: attachment.previewUrl,
|
||||
})),
|
||||
createdAt: now,
|
||||
|
|
@ -87,6 +95,34 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
setPending([])
|
||||
toast.error("Falha ao enviar comentário.", { id: "comment" })
|
||||
}
|
||||
previewsToRevoke.forEach((previewUrl) => {
|
||||
try {
|
||||
URL.revokeObjectURL(previewUrl)
|
||||
} catch (error) {
|
||||
console.error("Failed to revoke preview URL", error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleRemoveAttachment() {
|
||||
if (!attachmentToRemove || !userId) return
|
||||
setRemovingAttachment(true)
|
||||
toast.loading("Removendo anexo...", { id: "remove-attachment" })
|
||||
try {
|
||||
await removeAttachment({
|
||||
ticketId: ticket.id as unknown as Id<"tickets">,
|
||||
commentId: attachmentToRemove.commentId as Id<"ticketComments">,
|
||||
attachmentId: attachmentToRemove.attachmentId as Id<"_storage">,
|
||||
actorId: userId as Id<"users">,
|
||||
})
|
||||
toast.success("Anexo removido.", { id: "remove-attachment" })
|
||||
setAttachmentToRemove(null)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Não foi possível remover o anexo.", { id: "remove-attachment" })
|
||||
} finally {
|
||||
setRemovingAttachment(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -114,6 +150,9 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join("")
|
||||
const bodyHtml = comment.body ?? ""
|
||||
const bodyPlain = bodyHtml.replace(/<[^>]*>/g, "").trim()
|
||||
const hasBody = bodyPlain.length > 0
|
||||
|
||||
return (
|
||||
<div key={comment.id} className="flex gap-3">
|
||||
|
|
@ -133,39 +172,62 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700">
|
||||
<RichTextContent html={comment.body} />
|
||||
</div>
|
||||
{hasBody ? (
|
||||
<div className="break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700">
|
||||
<RichTextContent html={bodyHtml} />
|
||||
</div>
|
||||
) : null}
|
||||
{comment.attachments?.length ? (
|
||||
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
|
||||
{comment.attachments.map((attachment) => {
|
||||
const isImage = (attachment?.url ?? "").match(/\.(png|jpe?g|gif|webp|svg)$/i)
|
||||
if (isImage && attachment.url) {
|
||||
return (
|
||||
<button
|
||||
key={attachment.id}
|
||||
type="button"
|
||||
onClick={() => setPreview(attachment.url || null)}
|
||||
className="group overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5 hover:border-slate-400"
|
||||
>
|
||||
<img src={attachment.url} alt={attachment.name} className="h-24 w-24 rounded-md object-cover" />
|
||||
<div className="mt-1 line-clamp-1 w-24 text-ellipsis text-center text-[11px] text-neutral-500">
|
||||
{attachment.name}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
const name = attachment?.name ?? ""
|
||||
const url = attachment?.url
|
||||
const type = attachment?.type ?? ""
|
||||
const isImage =
|
||||
(!!type && type.startsWith("image/")) ||
|
||||
/\.(png|jpe?g|gif|webp|svg)$/i.test(name) ||
|
||||
/\.(png|jpe?g|gif|webp|svg)$/i.test(url ?? "")
|
||||
const openRemovalModal = (event: React.MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setAttachmentToRemove({ commentId: comment.id, attachmentId: attachment.id, name })
|
||||
}
|
||||
return (
|
||||
<a
|
||||
<div
|
||||
key={attachment.id}
|
||||
href={attachment.url}
|
||||
download={attachment.name}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 rounded-md border border-slate-200 px-2 py-1 text-xs text-neutral-800 hover:border-slate-400"
|
||||
className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm"
|
||||
>
|
||||
<FileIcon className="size-3.5 text-neutral-700" /> {attachment.name}
|
||||
{attachment.url ? <Download className="size-3.5 text-neutral-700" /> : null}
|
||||
</a>
|
||||
{isImage && url ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreview(url || null)}
|
||||
className="block w-full overflow-hidden rounded-md"
|
||||
>
|
||||
<img src={url} alt={name} className="h-24 w-full rounded-md object-cover" />
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href={url ?? undefined}
|
||||
download={name || undefined}
|
||||
target="_blank"
|
||||
className="flex h-24 w-full flex-col items-center justify-center gap-2 rounded-md bg-slate-50 text-xs text-neutral-700 transition hover:bg-slate-100"
|
||||
>
|
||||
<FileIcon className="size-5 text-neutral-600" />
|
||||
{url ? <span className="font-medium">Baixar</span> : <span>Pendente</span>}
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={openRemovalModal}
|
||||
aria-label={`Remover ${name}`}
|
||||
className="absolute right-1.5 top-1.5 inline-flex size-7 items-center justify-center rounded-full border border-black bg-black text-white opacity-0 transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30 focus-visible:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
<div className="mt-1 line-clamp-1 w-full text-ellipsis text-center text-[11px] text-neutral-500">
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -178,6 +240,55 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
|
||||
<RichTextEditor value={body} onChange={setBody} placeholder="Escreva um comentário..." />
|
||||
<Dropzone onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])} />
|
||||
{attachmentsToSend.length > 0 ? (
|
||||
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
|
||||
{attachmentsToSend.map((attachment, index) => {
|
||||
const name = attachment.name
|
||||
const previewUrl = attachment.previewUrl
|
||||
const isImage =
|
||||
(attachment.type ?? "").startsWith("image/") ||
|
||||
/\.(png|jpe?g|gif|webp|svg)$/i.test(name)
|
||||
return (
|
||||
<div key={`${attachment.storageId}-${index}`} className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5">
|
||||
{isImage && previewUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreview(previewUrl || null)}
|
||||
className="block w-full overflow-hidden rounded-md"
|
||||
>
|
||||
<img src={previewUrl} alt={name} className="h-24 w-full rounded-md object-cover" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex h-24 w-full flex-col items-center justify-center gap-2 rounded-md bg-slate-50 text-xs text-neutral-600">
|
||||
<FileIcon className="size-4" />
|
||||
<span className="line-clamp-2 px-2 text-center">{name}</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setAttachmentsToSend((prev) => {
|
||||
const next = [...prev]
|
||||
const removed = next.splice(index, 1)[0]
|
||||
if (removed?.previewUrl?.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(removed.previewUrl)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
className="absolute right-1.5 top-1.5 inline-flex size-7 items-center justify-center rounded-full border border-black bg-black text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30"
|
||||
aria-label={`Remover ${name}`}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
<div className="mt-1 line-clamp-1 w-full text-ellipsis text-center text-[11px] text-neutral-500">
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||
Visibilidade:
|
||||
|
|
@ -196,6 +307,36 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<Dialog open={!!attachmentToRemove} onOpenChange={(open) => { if (!open && !removingAttachment) setAttachmentToRemove(null) }}>
|
||||
<DialogContent className="max-w-sm space-y-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remover anexo</DialogTitle>
|
||||
<DialogDescription>
|
||||
Tem certeza de que deseja remover "{attachmentToRemove?.name}" deste comentário?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setAttachmentToRemove(null)}
|
||||
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-slate-100"
|
||||
disabled={removingAttachment}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleRemoveAttachment}
|
||||
disabled={removingAttachment}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-black bg-black px-4 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 disabled:opacity-60"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
{removingAttachment ? "Removendo..." : "Excluir"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={!!preview} onOpenChange={(open) => !open && setPreview(null)}>
|
||||
<DialogContent className="max-w-3xl p-0">
|
||||
{preview ? <img src={preview} alt="Preview" className="h-auto w-full rounded-xl" /> : null}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { format } from "date-fns"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { format, formatDistanceToNow } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
|
|
@ -26,15 +26,35 @@ interface TicketHeaderProps {
|
|||
ticket: TicketWithDetails
|
||||
}
|
||||
|
||||
const cardClass = "space-y-4 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
const cardClass = "relative space-y-4 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
const referenceBadgeClass = "inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-neutral-700"
|
||||
const startButtonClass = "inline-flex items-center gap-1 rounded-lg border border-black bg-[#00e8ff] px-3 py-1.5 text-sm font-semibold text-black transition hover:bg-[#00d6eb]"
|
||||
const pauseButtonClass = "inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-black/90"
|
||||
const editButtonClass = "inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-black/90"
|
||||
const startButtonClass =
|
||||
"inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30"
|
||||
const pauseButtonClass =
|
||||
"inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
|
||||
const editButtonClass =
|
||||
"inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
|
||||
const selectTriggerClass = "h-8 w-[220px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
|
||||
const smallSelectTriggerClass = "h-8 w-[180px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
|
||||
const sectionLabelClass = "text-xs font-semibold uppercase tracking-wide text-neutral-500"
|
||||
const sectionValueClass = "font-medium text-neutral-900"
|
||||
const subtleBadgeClass =
|
||||
"inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-2.5 py-0.5 text-[11px] font-medium text-neutral-600"
|
||||
|
||||
function formatDuration(durationMs: number) {
|
||||
if (durationMs <= 0) return "0s"
|
||||
const totalSeconds = Math.floor(durationMs / 1000)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes.toString().padStart(2, "0")}m`
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`
|
||||
}
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||
const { userId } = useAuth()
|
||||
|
|
@ -42,10 +62,19 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
const changeQueue = useMutation(api.tickets.changeQueue)
|
||||
const updateSubject = useMutation(api.tickets.updateSubject)
|
||||
const updateSummary = useMutation(api.tickets.updateSummary)
|
||||
const toggleWork = useMutation(api.tickets.toggleWork)
|
||||
const startWork = useMutation(api.tickets.startWork)
|
||||
const pauseWork = useMutation(api.tickets.pauseWork)
|
||||
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
|
||||
const queues = (useQuery(api.queues.summary, { tenantId: ticket.tenantId }) as TicketQueueSummary[] | undefined) ?? []
|
||||
const [status] = useState<TicketStatus>(ticket.status)
|
||||
const workSummaryRemote = useQuery(api.tickets.workSummary, { ticketId: ticket.id as Id<"tickets"> }) as
|
||||
| {
|
||||
ticketId: Id<"tickets">
|
||||
totalWorkedMs: number
|
||||
activeSession: { id: Id<"ticketWorkSessions">; agentId: Id<"users">; startedAt: number } | null
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [subject, setSubject] = useState(ticket.subject)
|
||||
|
|
@ -78,11 +107,53 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
setEditing(false)
|
||||
}
|
||||
|
||||
const lastWork = [...ticket.timeline].reverse().find((e) => e.type === "WORK_STARTED" || e.type === "WORK_PAUSED")
|
||||
const isPlaying = lastWork?.type === "WORK_STARTED"
|
||||
const workSummary = useMemo(() => {
|
||||
if (workSummaryRemote !== undefined) return workSummaryRemote ?? null
|
||||
if (!ticket.workSummary) return null
|
||||
return {
|
||||
ticketId: ticket.id as Id<"tickets">,
|
||||
totalWorkedMs: ticket.workSummary.totalWorkedMs,
|
||||
activeSession: ticket.workSummary.activeSession
|
||||
? {
|
||||
id: ticket.workSummary.activeSession.id as Id<"ticketWorkSessions">,
|
||||
agentId: ticket.workSummary.activeSession.agentId as Id<"users">,
|
||||
startedAt: ticket.workSummary.activeSession.startedAt.getTime(),
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}, [ticket.id, ticket.workSummary, workSummaryRemote])
|
||||
|
||||
const isPlaying = Boolean(workSummary?.activeSession)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
if (!workSummary?.activeSession) return
|
||||
const interval = setInterval(() => {
|
||||
setNow(Date.now())
|
||||
}, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [workSummary?.activeSession])
|
||||
|
||||
const currentSessionMs = workSummary?.activeSession ? Math.max(0, now - workSummary.activeSession.startedAt) : 0
|
||||
const totalWorkedMs = workSummary ? workSummary.totalWorkedMs + currentSessionMs : 0
|
||||
|
||||
const formattedTotalWorked = useMemo(() => formatDuration(totalWorkedMs), [totalWorkedMs])
|
||||
const formattedCurrentSession = useMemo(() => formatDuration(currentSessionMs), [currentSessionMs])
|
||||
const updatedRelative = useMemo(
|
||||
() => formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR }),
|
||||
[ticket.updatedAt]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cardClass}>
|
||||
<div className="absolute right-6 top-6 flex items-center gap-2">
|
||||
{!editing ? (
|
||||
<Button size="sm" className={editButtonClass} onClick={() => setEditing(true)}>
|
||||
Editar
|
||||
</Button>
|
||||
) : null}
|
||||
<DeleteTicketDialog ticketId={ticket.id as Id<"tickets">} />
|
||||
</div>
|
||||
<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">
|
||||
|
|
@ -94,9 +165,27 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
className={isPlaying ? pauseButtonClass : startButtonClass}
|
||||
onClick={async () => {
|
||||
if (!userId) return
|
||||
const next = await toggleWork({ ticketId: ticket.id as Id<"tickets">, actorId: userId as Id<"users"> })
|
||||
if (next) toast.success("Atendimento iniciado", { id: "work" })
|
||||
else toast.success("Atendimento pausado", { id: "work" })
|
||||
toast.dismiss("work")
|
||||
toast.loading(isPlaying ? "Pausando atendimento..." : "Iniciando atendimento...", { id: "work" })
|
||||
try {
|
||||
if (isPlaying) {
|
||||
const result = await pauseWork({ ticketId: ticket.id as Id<"tickets">, actorId: userId as Id<"users"> })
|
||||
if (result?.status === "already_paused") {
|
||||
toast.info("O atendimento já estava pausado", { id: "work" })
|
||||
} else {
|
||||
toast.success("Atendimento pausado", { id: "work" })
|
||||
}
|
||||
} else {
|
||||
const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: userId as Id<"users"> })
|
||||
if (result?.status === "already_started") {
|
||||
toast.info("O atendimento já estava em andamento", { id: "work" })
|
||||
} else {
|
||||
toast.success("Atendimento iniciado", { id: "work" })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error("Não foi possível atualizar o atendimento", { id: "work" })
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPlaying ? (
|
||||
|
|
@ -105,11 +194,23 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconPlayerPlay className="size-4 text-black" /> Iniciar
|
||||
<IconPlayerPlay className="size-4 text-white" /> Iniciar
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{workSummary ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-neutral-700">
|
||||
Tempo total: {formattedTotalWorked}
|
||||
</Badge>
|
||||
{isPlaying ? (
|
||||
<Badge className="inline-flex items-center gap-1 rounded-full border border-black bg-black px-3 py-1 text-xs font-semibold text-white">
|
||||
Sessão atual: {formattedCurrentSession}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{editing ? (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
|
|
@ -131,23 +232,16 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
{summary ? <p className="max-w-2xl text-sm text-neutral-600">{summary}</p> : null}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{editing ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" className="text-sm font-semibold text-neutral-700" onClick={handleCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button size="sm" className={startButtonClass} onClick={handleSave} disabled={!dirty}>
|
||||
Salvar
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" className={editButtonClass} onClick={() => setEditing(true)}>
|
||||
Editar
|
||||
{editing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" className="text-sm font-semibold text-neutral-700" onClick={handleCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
)}
|
||||
<DeleteTicketDialog ticketId={ticket.id as Id<"tickets">} />
|
||||
</div>
|
||||
<Button size="sm" className={startButtonClass} onClick={handleSave} disabled={!dirty}>
|
||||
Salvar
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="bg-slate-200" />
|
||||
|
|
@ -214,7 +308,10 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={sectionLabelClass}>Atualizado em</span>
|
||||
<span className={sectionValueClass}>{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={sectionValueClass}>{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
|
||||
<span className={subtleBadgeClass}>{updatedRelative}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={sectionLabelClass}>Criado em</span>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { ptBR } from "date-fns/locale"
|
|||
import {
|
||||
IconClockHour4,
|
||||
IconNote,
|
||||
IconPaperclip,
|
||||
IconSquareCheck,
|
||||
IconUserCircle,
|
||||
} from "@tabler/icons-react"
|
||||
|
|
@ -23,6 +24,8 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
|
|||
SUBJECT_CHANGED: IconNote,
|
||||
SUMMARY_CHANGED: IconNote,
|
||||
QUEUE_CHANGED: IconSquareCheck,
|
||||
PRIORITY_CHANGED: IconSquareCheck,
|
||||
ATTACHMENT_REMOVED: IconPaperclip,
|
||||
}
|
||||
|
||||
const timelineLabels: Record<string, string> = {
|
||||
|
|
@ -35,6 +38,8 @@ const timelineLabels: Record<string, string> = {
|
|||
SUBJECT_CHANGED: "Assunto atualizado",
|
||||
SUMMARY_CHANGED: "Resumo atualizado",
|
||||
QUEUE_CHANGED: "Fila alterada",
|
||||
PRIORITY_CHANGED: "Prioridade alterada",
|
||||
ATTACHMENT_REMOVED: "Anexo removido",
|
||||
}
|
||||
|
||||
interface TicketTimelineProps {
|
||||
|
|
@ -42,6 +47,21 @@ interface TicketTimelineProps {
|
|||
}
|
||||
|
||||
export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||
const formatDuration = (durationMs: number) => {
|
||||
if (!durationMs || durationMs <= 0) return "0s"
|
||||
const totalSeconds = Math.floor(durationMs / 1000)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes.toString().padStart(2, "0")}m`
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`
|
||||
}
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<CardContent className="space-y-5 px-4 pb-6">
|
||||
|
|
@ -88,6 +108,8 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
|||
authorName?: string
|
||||
authorId?: string
|
||||
from?: string
|
||||
attachmentName?: string
|
||||
sessionDurationMs?: number
|
||||
}
|
||||
|
||||
let message: string | null = null
|
||||
|
|
@ -100,6 +122,9 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
|||
if (entry.type === "QUEUE_CHANGED" && (payload.queueName || payload.queueId)) {
|
||||
message = "Fila alterada" + (payload.queueName ? " para " + payload.queueName : "")
|
||||
}
|
||||
if (entry.type === "PRIORITY_CHANGED" && (payload.toLabel || payload.to)) {
|
||||
message = "Prioridade alterada para " + (payload.toLabel || payload.to)
|
||||
}
|
||||
if (entry.type === "CREATED" && payload.requesterName) {
|
||||
message = "Criado por " + payload.requesterName
|
||||
}
|
||||
|
|
@ -112,6 +137,12 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
|||
if (entry.type === "SUMMARY_CHANGED") {
|
||||
message = "Resumo atualizado"
|
||||
}
|
||||
if (entry.type === "ATTACHMENT_REMOVED" && payload.attachmentName) {
|
||||
message = `Anexo removido: ${payload.attachmentName}`
|
||||
}
|
||||
if (entry.type === "WORK_PAUSED" && typeof payload.sessionDurationMs === "number") {
|
||||
message = `Tempo registrado: ${formatDuration(payload.sessionDurationMs)}`
|
||||
}
|
||||
if (!message) return null
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
|
|
@ -29,10 +32,26 @@ const channelLabel: Record<string, string> = {
|
|||
MANUAL: "Manual",
|
||||
}
|
||||
|
||||
const cellClass = "py-4 align-top"
|
||||
const queueBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-neutral-700"
|
||||
const channelBadgeClass = "inline-flex items-center gap-2 rounded-full border border-[#00c4d7]/40 bg-[#00e8ff]/15 px-2.5 py-1 text-xs font-semibold text-neutral-900"
|
||||
const tagBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-neutral-700"
|
||||
const cellClass = "px-6 py-5 align-top text-sm text-neutral-700 first:pl-8 last:pr-8"
|
||||
const queueBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-neutral-700"
|
||||
const channelBadgeClass = "inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-neutral-700"
|
||||
const tagBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-2.5 py-0.5 text-[11px] font-medium text-neutral-600"
|
||||
const tableRowClass = "group border-b border-slate-100 text-sm transition-colors hover:bg-slate-100/70 last:border-none"
|
||||
|
||||
function formatDuration(ms?: number) {
|
||||
if (!ms || ms <= 0) return "—"
|
||||
const totalSeconds = Math.floor(ms / 1000)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes.toString().padStart(2, "0")}m`
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`
|
||||
}
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
function AssigneeCell({ ticket }: { ticket: Ticket }) {
|
||||
if (!ticket.assignee) {
|
||||
|
|
@ -68,30 +87,67 @@ export type TicketsTableProps = {
|
|||
}
|
||||
|
||||
export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setNow(Date.now())
|
||||
}, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const getWorkedMs = (ticket: Ticket) => {
|
||||
const base = ticket.workSummary?.totalWorkedMs ?? 0
|
||||
const activeStart = ticket.workSummary?.activeSession?.startedAt
|
||||
if (activeStart instanceof Date) {
|
||||
return base + Math.max(0, now - activeStart.getTime())
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<CardContent className="px-4 py-4 sm:px-6">
|
||||
<Table className="min-w-full">
|
||||
<TableHeader>
|
||||
<TableRow className="text-[11px] uppercase tracking-wide text-neutral-500">
|
||||
<TableHead className="w-[110px]">Ticket</TableHead>
|
||||
<TableHead>Assunto</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Fila</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Canal</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Prioridade</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="hidden xl:table-cell">Responsável</TableHead>
|
||||
<TableHead className="w-[140px]">Atualizado</TableHead>
|
||||
<Card className="rounded-3xl border border-slate-200 bg-white shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<Table className="min-w-full overflow-hidden rounded-3xl">
|
||||
<TableHeader className="bg-slate-100/80">
|
||||
<TableRow className="bg-transparent text-[11px] uppercase tracking-wide text-neutral-600">
|
||||
<TableHead className="w-[120px] px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8">
|
||||
Ticket
|
||||
</TableHead>
|
||||
<TableHead className="px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8">
|
||||
Assunto
|
||||
</TableHead>
|
||||
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 lg:table-cell">
|
||||
Fila
|
||||
</TableHead>
|
||||
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 md:table-cell">
|
||||
Canal
|
||||
</TableHead>
|
||||
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 md:table-cell">
|
||||
Prioridade
|
||||
</TableHead>
|
||||
<TableHead className="px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8">
|
||||
Status
|
||||
</TableHead>
|
||||
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 lg:table-cell">
|
||||
Tempo
|
||||
</TableHead>
|
||||
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 xl:table-cell">
|
||||
Responsável
|
||||
</TableHead>
|
||||
<TableHead className="px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8">
|
||||
Atualizado
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tickets.map((ticket) => (
|
||||
<TableRow key={ticket.id} className="group border-b border-slate-100 transition hover:bg-[#00e8ff]/8">
|
||||
<TableRow key={ticket.id} className={tableRowClass}>
|
||||
<TableCell className={cellClass}>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link
|
||||
href={`/tickets/${ticket.id}`}
|
||||
className="font-semibold tracking-tight text-neutral-900 hover:text-[#00b8ce]"
|
||||
className="font-semibold tracking-tight text-neutral-900 transition hover:text-neutral-700"
|
||||
>
|
||||
#{ticket.reference}
|
||||
</Link>
|
||||
|
|
@ -101,18 +157,18 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
|||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={cellClass}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Link
|
||||
href={`/tickets/${ticket.id}`}
|
||||
className="line-clamp-1 font-semibold text-neutral-900 hover:text-[#00b8ce]"
|
||||
className="line-clamp-1 text-[15px] font-semibold text-neutral-900 transition hover:text-neutral-700"
|
||||
>
|
||||
{ticket.subject}
|
||||
</Link>
|
||||
<span className="line-clamp-1 text-sm text-neutral-600">
|
||||
{ticket.summary ?? "Sem resumo"}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2 text-xs text-neutral-500">
|
||||
<span className="font-medium text-neutral-900">{ticket.requester.name}</span>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-500">
|
||||
<span className="font-semibold text-neutral-700">{ticket.requester.name}</span>
|
||||
{ticket.tags?.map((tag) => (
|
||||
<Badge key={tag} className={tagBadgeClass}>
|
||||
{tag}
|
||||
|
|
@ -126,7 +182,7 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
|||
</TableCell>
|
||||
<TableCell className={`${cellClass} hidden md:table-cell`}>
|
||||
<Badge className={channelBadgeClass}>
|
||||
<span className="inline-block size-2 rounded-full border border-[#009bb1] bg-[#00e8ff]" />
|
||||
<span className="inline-block size-2 rounded-full bg-[#00d6eb]" />
|
||||
{channelLabel[ticket.channel] ?? ticket.channel}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
|
@ -143,11 +199,19 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
|||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={`${cellClass} hidden lg:table-cell`}>
|
||||
<div className="flex flex-col gap-1 text-sm text-neutral-600">
|
||||
<span className="font-semibold text-neutral-800">{formatDuration(getWorkedMs(ticket))}</span>
|
||||
{ticket.workSummary?.activeSession ? (
|
||||
<span className="text-xs text-neutral-500">Em andamento</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={`${cellClass} hidden xl:table-cell`}>
|
||||
<AssigneeCell ticket={ticket} />
|
||||
</TableCell>
|
||||
<TableCell className={cellClass}>
|
||||
<span className="text-sm text-neutral-500">
|
||||
<span className="text-sm text-neutral-600">
|
||||
{formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue