feat: preview de imagens com modal, download com nome correto; cartões (Conversa/Detalhes/Timeline) com sombra e padding; alias '@/convex/_generated/api'; payloads legíveis (nome de fila/responsável, label de status) e timeline amigável; Dropzone no 'Novo ticket' com comentário inicial; microtipografia refinada
This commit is contained in:
parent
90c3c8e4d6
commit
44c98fec4a
24 changed files with 1409 additions and 301 deletions
|
|
@ -4,15 +4,19 @@ import { z } from "zod"
|
|||
import { useState } from "react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
// @ts-ignore
|
||||
import { api } from "../../../convex/_generated/api"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { FieldSet, FieldGroup, Field, FieldLabel, FieldError } from "@/components/ui/field"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { toast } from "sonner"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Dropzone } from "@/components/ui/dropzone"
|
||||
|
||||
const schema = z.object({
|
||||
subject: z.string().min(3, "Informe um assunto"),
|
||||
|
|
@ -25,18 +29,18 @@ const schema = z.object({
|
|||
export function NewTicketDialog() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [values, setValues] = useState<z.infer<typeof schema>>({ subject: "", summary: "", priority: "MEDIUM", channel: "MANUAL", queueName: null })
|
||||
const form = useForm<z.infer<typeof schema>>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { subject: "", summary: "", priority: "MEDIUM", channel: "MANUAL", queueName: null },
|
||||
mode: "onTouched",
|
||||
})
|
||||
const { userId } = useAuth()
|
||||
const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? []
|
||||
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 }>>([])
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const parsed = schema.safeParse(values)
|
||||
if (!parsed.success) {
|
||||
toast.error(parsed.error.issues[0]?.message ?? "Preencha o formulário")
|
||||
return
|
||||
}
|
||||
async function submit(values: z.infer<typeof schema>) {
|
||||
if (!userId) return
|
||||
setLoading(true)
|
||||
toast.loading("Criando ticket…", { id: "new-ticket" })
|
||||
|
|
@ -51,9 +55,13 @@ export function NewTicketDialog() {
|
|||
queueId: sel?.id,
|
||||
requesterId: userId as any,
|
||||
})
|
||||
if (attachments.length > 0 || (values.summary && values.summary.trim().length > 0)) {
|
||||
await addComment({ ticketId: id as any, authorId: userId as any, visibility: "PUBLIC", body: values.summary || "", attachments })
|
||||
}
|
||||
toast.success("Ticket criado!", { id: "new-ticket" })
|
||||
setOpen(false)
|
||||
setValues({ subject: "", summary: "", priority: "MEDIUM", channel: "MANUAL", queueName: null })
|
||||
form.reset()
|
||||
setAttachments([])
|
||||
// Navegar para o ticket recém-criado
|
||||
window.location.href = `/tickets/${id}`
|
||||
} catch (err) {
|
||||
|
|
@ -73,55 +81,65 @@ export function NewTicketDialog() {
|
|||
<DialogTitle>Novo ticket</DialogTitle>
|
||||
<DialogDescription>Preencha as informações básicas para abrir um chamado.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={submit}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm" htmlFor="subject">Assunto</label>
|
||||
<Input id="subject" value={values.subject} onChange={(e) => setValues((v) => ({ ...v, subject: e.target.value }))} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm" htmlFor="summary">Resumo</label>
|
||||
<textarea id="summary" className="w-full rounded-md border bg-background p-2 text-sm" rows={3} value={values.summary} onChange={(e) => setValues((v) => ({ ...v, summary: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm">Prioridade</label>
|
||||
<Select value={values.priority} onValueChange={(v) => setValues((s) => ({ ...s, priority: v as any }))}>
|
||||
<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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm">Canal</label>
|
||||
<Select value={values.channel} onValueChange={(v) => setValues((s) => ({ ...s, channel: v as any }))}>
|
||||
<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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm">Fila</label>
|
||||
<Select value={values.queueName ?? ""} onValueChange={(v) => setValues((s) => ({ ...s, queueName: v || null }))}>
|
||||
<SelectTrigger><SelectValue placeholder="Sem fila" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Sem fila</SelectItem>
|
||||
{queues.map((q: any) => (
|
||||
<SelectItem key={q.id} value={q.name}>{q.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<form className="space-y-4" onSubmit={form.handleSubmit(submit)}>
|
||||
<FieldSet>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="subject">Assunto</FieldLabel>
|
||||
<Input id="subject" {...form.register("subject")} placeholder="Ex.: Erro 500 no portal" />
|
||||
<FieldError errors={form.formState.errors.subject ? [{ message: form.formState.errors.subject.message }] : []} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="summary">Resumo</FieldLabel>
|
||||
<textarea id="summary" className="w-full rounded-md border bg-background p-2 text-sm" rows={3} {...form.register("summary")} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Anexos</FieldLabel>
|
||||
<Dropzone onUploaded={(files) => setAttachments((prev) => [...prev, ...files])} />
|
||||
<FieldError className="mt-1">Formatos comuns de imagem e documentos são aceitos.</FieldError>
|
||||
</Field>
|
||||
<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 any)}>
|
||||
<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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Canal</FieldLabel>
|
||||
<Select value={form.watch("channel")} onValueChange={(v) => form.setValue("channel", v as any)}>
|
||||
<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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Fila</FieldLabel>
|
||||
<Select value={form.watch("queueName") ?? ""} onValueChange={(v) => form.setValue("queueName", v || null)}>
|
||||
<SelectTrigger><SelectValue placeholder="Sem fila" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Sem fila</SelectItem>
|
||||
{queues.map((q: any) => (
|
||||
<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>
|
||||
</div>
|
||||
|
|
@ -130,4 +148,3 @@ export function NewTicketDialog() {
|
|||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react"
|
|||
import { useMutation, useQuery } from "convex/react"
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
import { api } from "../../../convex/_generated/api"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import type { TicketPlayContext } from "@/lib/schemas/ticket"
|
||||
|
|
|
|||
26
web/src/components/tickets/recent-tickets-panel.tsx
Normal file
26
web/src/components/tickets/recent-tickets-panel.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "convex/react";
|
||||
// @ts-ignore
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants";
|
||||
import { mapTicketsFromServerList } from "@/lib/mappers/ticket";
|
||||
import { TicketsTable } from "@/components/tickets/tickets-table";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
export function RecentTicketsPanel() {
|
||||
const ticketsRaw = useQuery(api.tickets.list, { tenantId: DEFAULT_TENANT_ID, limit: 10 });
|
||||
if (ticketsRaw === undefined) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-6 text-sm text-muted-foreground">
|
||||
<Spinner className="me-2" /> Carregando tickets…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const tickets = mapTicketsFromServerList(ticketsRaw as any[]);
|
||||
return (
|
||||
<div className="rounded-xl border bg-card">
|
||||
<TicketsTable tickets={tickets as any} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,10 +4,11 @@ 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, ImageIcon, FileIcon } from "lucide-react"
|
||||
import { useAction, useMutation } from "convex/react"
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
import { api } from "../../../convex/_generated/api"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
|
|
@ -15,6 +16,8 @@ import { Badge } from "@/components/ui/badge"
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { toast } from "sonner"
|
||||
import { Dropzone } from "@/components/ui/dropzone"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
|
||||
interface TicketCommentsProps {
|
||||
ticket: TicketWithDetails
|
||||
|
|
@ -25,7 +28,8 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
const addComment = useMutation(api.tickets.addComment)
|
||||
const generateUploadUrl = useAction(api.files.generateUploadUrl)
|
||||
const [body, setBody] = useState("")
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
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 commentsAll = useMemo(() => {
|
||||
|
|
@ -35,30 +39,20 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!userId) return
|
||||
let attachments: Array<{ storageId: string; name: string; size?: number; type?: string }> = []
|
||||
if (files.length) {
|
||||
const url = await generateUploadUrl({})
|
||||
for (const file of files) {
|
||||
const form = new FormData()
|
||||
form.append("file", file)
|
||||
const res = await fetch(url, { method: "POST", body: form })
|
||||
const { storageId } = await res.json()
|
||||
attachments.push({ storageId, name: file.name, size: file.size, type: file.type })
|
||||
}
|
||||
}
|
||||
const attachments = attachmentsToSend
|
||||
const now = new Date()
|
||||
const optimistic = {
|
||||
id: `temp-${now.getTime()}`,
|
||||
author: ticket.requester, // placeholder; poderia buscar o próprio usuário se necessário
|
||||
visibility: "PUBLIC" as const,
|
||||
body,
|
||||
attachments: attachments.map((a) => ({ id: a.storageId, name: a.name } as any)),
|
||||
attachments: attachments.map((a) => ({ id: a.storageId, name: a.name, url: a.previewUrl } as any)),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
setPending((p) => [optimistic, ...p])
|
||||
setBody("")
|
||||
setFiles([])
|
||||
setAttachmentsToSend([])
|
||||
toast.loading("Enviando comentário…", { id: "comment" })
|
||||
try {
|
||||
await addComment({ ticketId: ticket.id as any, authorId: userId as any, visibility: "PUBLIC", body: optimistic.body, attachments })
|
||||
|
|
@ -71,13 +65,13 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<Card className="border-none shadow-none">
|
||||
<CardHeader className="px-0">
|
||||
<Card className="rounded-xl border bg-card shadow-sm">
|
||||
<CardHeader className="px-4">
|
||||
<CardTitle className="flex items-center gap-2 text-lg font-semibold">
|
||||
<IconMessage className="size-5" /> Conversa
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 px-0">
|
||||
<CardContent className="space-y-6 px-4 pb-6">
|
||||
{commentsAll.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ainda sem comentarios. Que tal registrar o proximo passo?
|
||||
|
|
@ -110,15 +104,36 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground break-words whitespace-pre-wrap">
|
||||
{comment.body}
|
||||
</div>
|
||||
{comment.attachments?.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{comment.attachments.map((a) => (
|
||||
<a key={(a as any).id} href={(a as any).url} target="_blank" className="text-xs underline">
|
||||
{(a as any).name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{comment.attachments?.length ? (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{comment.attachments.map((a) => {
|
||||
const att = a as any
|
||||
const isImg = (att?.url ?? "").match(/\.(png|jpe?g|gif|webp|svg)$/i)
|
||||
if (isImg && att.url) {
|
||||
return (
|
||||
<button
|
||||
key={att.id}
|
||||
type="button"
|
||||
onClick={() => setPreview(att.url)}
|
||||
className="group overflow-hidden rounded-lg border bg-card p-0.5 hover:shadow"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={att.url} alt={att.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-muted-foreground">
|
||||
{att.name}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a key={att.id} href={att.url} download={att.name} target="_blank" className="flex items-center gap-2 rounded-md border px-2 py-1 text-xs hover:bg-muted">
|
||||
<FileIcon className="size-3.5" /> {att.name}
|
||||
{att.url ? <Download className="size-3.5" /> : null}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -132,11 +147,19 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<input type="file" multiple onChange={(e) => setFiles(Array.from(e.target.files ?? []))} />
|
||||
<Dropzone onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])} />
|
||||
<div className="flex items-center justify-end">
|
||||
<Button type="submit" size="sm">Enviar</Button>
|
||||
</div>
|
||||
</form>
|
||||
<Dialog open={!!preview} onOpenChange={(o) => !o && setPreview(null)}>
|
||||
<DialogContent className="max-w-3xl p-0">
|
||||
{preview ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={preview} alt="Preview" className="h-auto w-full rounded-xl" />
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
|
|
|||
21
web/src/components/tickets/ticket-detail-static.tsx
Normal file
21
web/src/components/tickets/ticket-detail-static.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"use client";
|
||||
|
||||
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
|
||||
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
|
||||
import { TicketTimeline } from "@/components/tickets/ticket-timeline";
|
||||
import type { TicketWithDetails } from "@/lib/schemas/ticket";
|
||||
|
||||
export function TicketDetailStatic({ ticket }: { ticket: TicketWithDetails }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
||||
<TicketSummaryHeader ticket={ticket} />
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
<div className="space-y-6">
|
||||
<TicketTimeline ticket={ticket} />
|
||||
</div>
|
||||
<TicketDetailsPanel ticket={ticket} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -3,18 +3,25 @@
|
|||
import { useQuery } from "convex/react";
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants";
|
||||
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket";
|
||||
import { getTicketById } from "@/lib/mocks/tickets";
|
||||
import { TicketComments } from "@/components/tickets/ticket-comments";
|
||||
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
|
||||
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
|
||||
import { TicketTimeline } from "@/components/tickets/ticket-timeline";
|
||||
|
||||
export function TicketDetailView({ id }: { id: string }) {
|
||||
const t = useQuery(api.tickets.getById, { tenantId: DEFAULT_TENANT_ID, id: id as any });
|
||||
if (!t) return <div className="px-4 py-8 text-sm text-muted-foreground">Carregando ticket...</div>;
|
||||
const ticket = mapTicketWithDetailsFromServer(t as any)
|
||||
const isMockId = id.startsWith("ticket-");
|
||||
const t = useQuery(api.tickets.getById, isMockId ? undefined : ({ tenantId: DEFAULT_TENANT_ID, id: id as any }));
|
||||
let ticket: any | null = null;
|
||||
if (t) {
|
||||
ticket = mapTicketWithDetailsFromServer(t as any);
|
||||
} else if (isMockId) {
|
||||
ticket = getTicketById(id) ?? null;
|
||||
}
|
||||
if (!ticket) return <div className="px-4 py-8 text-sm text-muted-foreground">Carregando ticket...</div>;
|
||||
return (
|
||||
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
||||
<TicketSummaryHeader ticket={ticket as any} />
|
||||
|
|
|
|||
|
|
@ -13,11 +13,11 @@ interface TicketDetailsPanelProps {
|
|||
|
||||
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||
return (
|
||||
<Card className="border-none shadow-none">
|
||||
<CardHeader className="px-0">
|
||||
<Card className="rounded-xl border bg-card shadow-sm">
|
||||
<CardHeader className="px-4">
|
||||
<CardTitle className="text-lg font-semibold">Detalhes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-5 px-0 text-sm text-muted-foreground">
|
||||
<CardContent className="flex flex-col gap-5 px-4 pb-6 text-sm text-muted-foreground">
|
||||
<div className="space-y-1 break-words">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide">Fila</p>
|
||||
<Badge variant="outline" className="max-w-full truncate">{ticket.queue ?? "Sem fila"}</Badge>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useQuery } from "convex/react"
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
import { api } from "../../../convex/_generated/api"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import type { TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { useMutation, useQuery } from "convex/react"
|
|||
import { toast } from "sonner"
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
import { api } from "../../../convex/_generated/api"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
||||
|
|
|
|||
|
|
@ -25,16 +25,17 @@ const timelineLabels: Record<string, string> = {
|
|||
STATUS_CHANGED: "Status alterado",
|
||||
ASSIGNEE_CHANGED: "Responsável alterado",
|
||||
COMMENT_ADDED: "Comentário adicionado",
|
||||
QUEUE_CHANGED: "Fila alterada",
|
||||
}
|
||||
|
||||
interface TicketTimelineProps {
|
||||
ticket: TicketWithDetails
|
||||
}
|
||||
|
||||
export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||
return (
|
||||
<Card className="border-none shadow-none">
|
||||
<CardContent className="space-y-6">
|
||||
export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||
return (
|
||||
<Card className="rounded-xl border bg-card shadow-sm">
|
||||
<CardContent className="space-y-6 px-4 pb-6">
|
||||
{ticket.timeline.map((entry, index) => {
|
||||
const Icon = timelineIcons[entry.type] ?? IconClockHour4
|
||||
const isLast = index === ticket.timeline.length - 1
|
||||
|
|
@ -55,13 +56,19 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
|||
{format(entry.createdAt, "dd MMM yyyy HH:mm", { locale: ptBR })}
|
||||
</span>
|
||||
</div>
|
||||
{entry.payload ? (
|
||||
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm text-muted-foreground">
|
||||
<pre className="whitespace-pre-wrap leading-relaxed">
|
||||
{JSON.stringify(entry.payload, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
{(() => {
|
||||
const p: any = entry.payload || {}
|
||||
let message: string | null = null
|
||||
if (entry.type === "STATUS_CHANGED" && (p.toLabel || p.to)) message = `Status alterado para ${p.toLabel || p.to}`
|
||||
if (entry.type === "ASSIGNEE_CHANGED" && (p.assigneeName || p.assigneeId)) message = `Responsável alterado${p.assigneeName ? ` para ${p.assigneeName}` : ""}`
|
||||
if (entry.type === "QUEUE_CHANGED" && (p.queueName || p.queueId)) message = `Fila alterada${p.queueName ? ` para ${p.queueName}` : ""}`
|
||||
if (!message) return null
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm text-muted-foreground">
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -63,110 +63,110 @@ type TicketsTableProps = {
|
|||
}
|
||||
|
||||
export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||
return (
|
||||
<Card className="border-none shadow-none">
|
||||
<CardContent className="px-4 py-4 sm:px-6">
|
||||
<Table className="min-w-full">
|
||||
<TableHeader>
|
||||
<TableRow className="text-xs uppercase text-muted-foreground">
|
||||
<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>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tickets.map((ticket) => (
|
||||
<TableRow key={ticket.id} className="group">
|
||||
<TableCell className={cellClass}>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Link
|
||||
href={`/tickets/${ticket.id}`}
|
||||
className="font-semibold tracking-tight text-primary hover:underline"
|
||||
>
|
||||
#{ticket.reference}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{ticket.queue ?? "Sem fila"}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={cellClass}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link
|
||||
href={`/tickets/${ticket.id}`}
|
||||
className="line-clamp-1 font-medium text-foreground hover:underline"
|
||||
>
|
||||
{ticket.subject}
|
||||
</Link>
|
||||
<span className="line-clamp-1 text-sm text-muted-foreground">
|
||||
{ticket.summary ?? "Sem resumo"}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span>{ticket.requester.name}</span>
|
||||
{ticket.tags?.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="outline"
|
||||
className="rounded-full border-transparent bg-slate-100 px-2 py-1 text-xs text-slate-600"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={`${cellClass} hidden lg:table-cell`}>
|
||||
<Badge className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600">
|
||||
{ticket.queue ?? "Sem fila"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className={`${cellClass} hidden md:table-cell`}>
|
||||
<Badge className="rounded-full bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-600">
|
||||
{channelLabel[ticket.channel] ?? ticket.channel}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className={`${cellClass} hidden md:table-cell`}>
|
||||
<TicketPriorityPill priority={ticket.priority} />
|
||||
</TableCell>
|
||||
<TableCell className={cellClass}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
{ticket.metrics?.timeWaitingMinutes ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Espera {ticket.metrics.timeWaitingMinutes} min
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={`${cellClass} hidden xl:table-cell`}>
|
||||
<AssigneeCell ticket={ticket} />
|
||||
</TableCell>
|
||||
<TableCell className={cellClass}>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDistanceToNow(ticket.updatedAt, {
|
||||
addSuffix: true,
|
||||
locale: ptBR,
|
||||
})}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{tickets.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center">
|
||||
<p className="text-sm font-medium">Nenhum ticket encontrado</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ajuste os filtros ou selecione outra fila.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Card className="border bg-card/90 shadow-sm">
|
||||
<CardContent className="px-4 py-4 sm:px-6">
|
||||
<Table className="min-w-full">
|
||||
<TableHeader>
|
||||
<TableRow className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<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>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tickets.map((ticket) => (
|
||||
<TableRow key={ticket.id} className="group hover:bg-muted/40">
|
||||
<TableCell className={cellClass}>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Link
|
||||
href={`/tickets/${ticket.id}`}
|
||||
className="font-semibold tracking-tight text-primary hover:underline"
|
||||
>
|
||||
#{ticket.reference}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{ticket.queue ?? "Sem fila"}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={cellClass}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link
|
||||
href={`/tickets/${ticket.id}`}
|
||||
className="line-clamp-1 font-medium text-foreground hover:underline"
|
||||
>
|
||||
{ticket.subject}
|
||||
</Link>
|
||||
<span className="line-clamp-1 text-sm text-muted-foreground">
|
||||
{ticket.summary ?? "Sem resumo"}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span>{ticket.requester.name}</span>
|
||||
{ticket.tags?.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="outline"
|
||||
className="rounded-full border-transparent bg-slate-100 px-2 py-1 text-xs text-slate-600"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={`${cellClass} hidden lg:table-cell`}>
|
||||
<Badge className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600">
|
||||
{ticket.queue ?? "Sem fila"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className={`${cellClass} hidden md:table-cell`}>
|
||||
<Badge className="rounded-full bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-600">
|
||||
{channelLabel[ticket.channel] ?? ticket.channel}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className={`${cellClass} hidden md:table-cell`}>
|
||||
<TicketPriorityPill priority={ticket.priority} />
|
||||
</TableCell>
|
||||
<TableCell className={cellClass}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
{ticket.metrics?.timeWaitingMinutes ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Espera {ticket.metrics.timeWaitingMinutes} min
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={`${cellClass} hidden xl:table-cell`}>
|
||||
<AssigneeCell ticket={ticket} />
|
||||
</TableCell>
|
||||
<TableCell className={cellClass}>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDistanceToNow(ticket.updatedAt, {
|
||||
addSuffix: true,
|
||||
locale: ptBR,
|
||||
})}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{tickets.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center text-sm">
|
||||
<p className="text-sm font-medium">Nenhum ticket encontrado</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ajuste os filtros ou selecione outra fila.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useMemo, useState } from "react"
|
|||
import { useQuery } from "convex/react"
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
import { api } from "../../../convex/_generated/api"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
|
||||
import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue