feat: improve ticket export and navigation
This commit is contained in:
parent
0731c5d1ea
commit
7d6f3bea01
28 changed files with 1612 additions and 609 deletions
|
|
@ -55,6 +55,25 @@ type Props = {
|
|||
defaultTenantId: string
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
admin: "Administrador",
|
||||
manager: "Gestor",
|
||||
agent: "Agente",
|
||||
collaborator: "Colaborador",
|
||||
machine: "Agente de máquina",
|
||||
}
|
||||
|
||||
function formatRole(role: string) {
|
||||
const key = role?.toLowerCase?.() ?? ""
|
||||
return ROLE_LABELS[key] ?? role
|
||||
}
|
||||
|
||||
function formatTenantLabel(tenantId: string, defaultTenantId: string) {
|
||||
if (!tenantId) return "Principal"
|
||||
if (tenantId === defaultTenantId) return "Principal"
|
||||
return tenantId
|
||||
}
|
||||
|
||||
function formatDate(dateIso: string) {
|
||||
const date = new Date(dateIso)
|
||||
return new Intl.DateTimeFormat("pt-BR", {
|
||||
|
|
@ -206,8 +225,6 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
<TabsList className="h-12 w-full justify-start rounded-xl bg-slate-100 p-1">
|
||||
<TabsTrigger value="invites" className="rounded-lg">Convites</TabsTrigger>
|
||||
<TabsTrigger value="users" className="rounded-lg">Usuários</TabsTrigger>
|
||||
<TabsTrigger value="queues" className="rounded-lg">Filas</TabsTrigger>
|
||||
<TabsTrigger value="categories" className="rounded-lg">Categorias</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="invites" className="mt-6 space-y-6">
|
||||
|
|
@ -255,25 +272,23 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
<SelectContent>
|
||||
{normalizedRoles.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item === "admin"
|
||||
? "Administrador"
|
||||
: item === "manager"
|
||||
? "Gestor"
|
||||
: item === "agent"
|
||||
? "Agente"
|
||||
: "Colaborador"}
|
||||
{formatRole(item)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invite-tenant">Tenant</Label>
|
||||
<Label htmlFor="invite-tenant">Espaço (ID interno)</Label>
|
||||
<Input
|
||||
id="invite-tenant"
|
||||
value={tenantId}
|
||||
onChange={(event) => setTenantId(event.target.value)}
|
||||
placeholder="ex.: principal"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Use este campo apenas se trabalhar com múltiplos espaços de clientes. Caso contrário, mantenha o valor padrão.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Expira em</Label>
|
||||
|
|
@ -377,7 +392,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
|
||||
<th className="py-3 pr-4 font-medium">Colaborador</th>
|
||||
<th className="py-3 pr-4 font-medium">Papel</th>
|
||||
<th className="py-3 pr-4 font-medium">Tenant</th>
|
||||
<th className="py-3 pr-4 font-medium">Espaço</th>
|
||||
<th className="py-3 pr-4 font-medium">Expira em</th>
|
||||
<th className="py-3 pr-4 font-medium">Status</th>
|
||||
<th className="py-3 font-medium">Ações</th>
|
||||
|
|
@ -392,8 +407,8 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
<span className="text-xs text-neutral-500">{invite.email}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4 uppercase text-neutral-600">{invite.role}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{invite.tenantId}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatRole(invite.role)}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatTenantLabel(invite.tenantId, defaultTenantId)}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatDate(invite.expiresAt)}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<Badge
|
||||
|
|
@ -449,7 +464,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
<th className="py-3 pr-4 font-medium">Nome</th>
|
||||
<th className="py-3 pr-4 font-medium">E-mail</th>
|
||||
<th className="py-3 pr-4 font-medium">Papel</th>
|
||||
<th className="py-3 pr-4 font-medium">Tenant</th>
|
||||
<th className="py-3 pr-4 font-medium">Espaço</th>
|
||||
<th className="py-3 font-medium">Criado em</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -458,8 +473,8 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
<tr key={user.id} className="hover:bg-slate-50">
|
||||
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "—"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
|
||||
<td className="py-3 pr-4 uppercase text-neutral-600">{user.role}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.tenantId}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatRole(user.role)}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatTenantLabel(user.tenantId, defaultTenantId)}</td>
|
||||
<td className="py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -476,27 +491,6 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="queues" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Gestão de filas</CardTitle>
|
||||
<CardDescription>
|
||||
Em breve será possível criar e reordenar as filas utilizadas na triagem dos tickets.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="categories" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Gestão de categorias</CardTitle>
|
||||
<CardDescription>
|
||||
Estamos preparando o painel completo para organizar categorias e subcategorias do catálogo.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,12 @@ import {
|
|||
Layers3,
|
||||
UserPlus,
|
||||
BellRing,
|
||||
ChevronDown,
|
||||
} from "lucide-react"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
import { SearchForm } from "@/components/search-form"
|
||||
import { VersionSwitcher } from "@/components/version-switcher"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
import { SearchForm } from "@/components/search-form"
|
||||
import { VersionSwitcher } from "@/components/version-switcher"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
|
|
@ -39,6 +40,7 @@ import {
|
|||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { NavUser } from "@/components/nav-user"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
|
||||
|
|
@ -47,9 +49,10 @@ type NavRoleRequirement = "staff" | "admin"
|
|||
type NavigationItem = {
|
||||
title: string
|
||||
url: string
|
||||
icon: LucideIcon
|
||||
icon?: LucideIcon
|
||||
requiredRole?: NavRoleRequirement
|
||||
exact?: boolean
|
||||
children?: NavigationItem[]
|
||||
}
|
||||
|
||||
type NavigationGroup = {
|
||||
|
|
@ -65,7 +68,13 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
|
|||
title: "Operação",
|
||||
items: [
|
||||
{ title: "Dashboard", url: "/dashboard", icon: LayoutDashboard, requiredRole: "staff" },
|
||||
{ title: "Tickets", url: "/tickets", icon: Ticket, requiredRole: "staff" },
|
||||
{
|
||||
title: "Tickets",
|
||||
url: "/tickets",
|
||||
icon: Ticket,
|
||||
requiredRole: "staff",
|
||||
children: [{ title: "Resolvidos", url: "/tickets/resolved", requiredRole: "staff" }],
|
||||
},
|
||||
{ title: "Visualizações", url: "/views", icon: PanelsTopLeft, requiredRole: "staff" },
|
||||
{ title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" },
|
||||
],
|
||||
|
|
@ -105,9 +114,34 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
|
|||
}
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const pathname = usePathname()
|
||||
const pathname = usePathname()
|
||||
const { session, isLoading, isAdmin, isStaff } = useAuth()
|
||||
const [isHydrated, setIsHydrated] = React.useState(false)
|
||||
const initialExpanded = React.useMemo(() => {
|
||||
const open = new Set<string>()
|
||||
navigation.navMain.forEach((group) => {
|
||||
group.items.forEach((item) => {
|
||||
if (!item.children || item.children.length === 0) return
|
||||
const shouldOpen = item.children.some((child) => {
|
||||
if (!canAccess(child.requiredRole)) return false
|
||||
return pathname === child.url || pathname.startsWith(`${child.url}/`)
|
||||
})
|
||||
if (shouldOpen) {
|
||||
open.add(item.title)
|
||||
}
|
||||
})
|
||||
})
|
||||
return open
|
||||
}, [pathname])
|
||||
const [expanded, setExpanded] = React.useState<Set<string>>(initialExpanded)
|
||||
|
||||
React.useEffect(() => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev)
|
||||
initialExpanded.forEach((key) => next.add(key))
|
||||
return next
|
||||
})
|
||||
}, [initialExpanded])
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsHydrated(true)
|
||||
|
|
@ -131,6 +165,18 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
if (requiredRole === "staff") return isStaff
|
||||
return false
|
||||
}
|
||||
|
||||
const toggleExpanded = React.useCallback((title: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(title)) {
|
||||
next.delete(title)
|
||||
} else {
|
||||
next.add(title)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (!isHydrated) {
|
||||
return (
|
||||
|
|
@ -180,16 +226,64 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
<SidebarGroupLabel>{group.title}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{visibleItems.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild isActive={isActive(item)}>
|
||||
<a href={item.url} className="gap-2">
|
||||
<item.icon className="size-4" />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{visibleItems.map((item) => {
|
||||
if (item.children && item.children.length > 0) {
|
||||
const childItems = item.children.filter((child) => canAccess(child.requiredRole))
|
||||
const isExpanded = expanded.has(item.title)
|
||||
const isChildActive = childItems.some((child) => isActive(child))
|
||||
const parentActive = isActive(item) || isChildActive
|
||||
|
||||
return (
|
||||
<React.Fragment key={item.title}>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={parentActive}>
|
||||
<a href={item.url} className={cn("gap-2", "relative pr-7") }>
|
||||
{item.icon ? <item.icon className="size-4" /> : null}
|
||||
<span className="flex-1">{item.title}</span>
|
||||
<span
|
||||
role="button"
|
||||
aria-label={isExpanded ? "Recolher submenu" : "Expandir submenu"}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleExpanded(item.title)
|
||||
}}
|
||||
className={cn(
|
||||
"absolute right-1.5 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-neutral-500 transition hover:bg-slate-200 hover:text-neutral-700",
|
||||
isExpanded && "rotate-180"
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="size-3" />
|
||||
</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{isExpanded
|
||||
? childItems.map((child) => (
|
||||
<SidebarMenuItem key={`${item.title}-${child.title}`}>
|
||||
<SidebarMenuButton asChild isActive={isActive(child)}>
|
||||
<a href={child.url} className="gap-2 pl-7 text-sm">
|
||||
<span>{child.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))
|
||||
: null}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild isActive={isActive(item)}>
|
||||
<a href={item.url} className="gap-2">
|
||||
{item.icon ? <item.icon className="size-4" /> : null}
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
|
|
|||
|
|
@ -26,9 +26,11 @@ export function PortalShell({ children }: PortalShellProps) {
|
|||
const { session, machineContext } = useAuth()
|
||||
const [isSigningOut, setIsSigningOut] = useState(false)
|
||||
|
||||
const isMachineSession = session?.user.role === "machine"
|
||||
const personaValue = machineContext?.persona ?? session?.user.machinePersona ?? null
|
||||
const displayName = machineContext?.assignedUserName ?? session?.user.name ?? session?.user.email ?? "Cliente"
|
||||
const displayEmail = machineContext?.assignedUserEmail ?? session?.user.email ?? ""
|
||||
const personaLabel = machineContext?.persona === "manager" ? "Gestor" : "Colaborador"
|
||||
const personaLabel = personaValue === "manager" ? "Gestor" : "Colaborador"
|
||||
|
||||
const initials = useMemo(() => {
|
||||
const name = displayName || displayEmail || "Cliente"
|
||||
|
|
@ -64,7 +66,7 @@ export function PortalShell({ children }: PortalShellProps) {
|
|||
<GalleryVerticalEnd className="size-4" />
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.28em] text-neutral-500">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.12em] text-neutral-500">
|
||||
Portal do cliente
|
||||
</span>
|
||||
<span className="text-lg font-semibold text-neutral-900">Raven</span>
|
||||
|
|
@ -100,12 +102,12 @@ export function PortalShell({ children }: PortalShellProps) {
|
|||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-semibold text-neutral-900">{displayName}</span>
|
||||
<span className="text-xs text-neutral-500">{displayEmail}</span>
|
||||
{machineContext ? (
|
||||
{personaValue ? (
|
||||
<span className="text-[10px] uppercase tracking-wide text-neutral-400">{personaLabel}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{!machineContext ? (
|
||||
{!isMachineSession ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -31,11 +31,12 @@ function toHtml(text: string) {
|
|||
|
||||
export function PortalTicketForm() {
|
||||
const router = useRouter()
|
||||
const { convexUserId, session } = useAuth()
|
||||
const { convexUserId, session, machineContext } = useAuth()
|
||||
const createTicket = useMutation(api.tickets.create)
|
||||
const addComment = useMutation(api.tickets.addComment)
|
||||
|
||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
const viewerId = (convexUserId ?? machineContext?.assignedUserId ?? null) as Id<"users"> | null
|
||||
|
||||
const [subject, setSubject] = useState("")
|
||||
const [summary, setSummary] = useState("")
|
||||
|
|
@ -51,7 +52,7 @@ export function PortalTicketForm() {
|
|||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (!convexUserId || !isFormValid || isSubmitting) return
|
||||
if (!viewerId || !isFormValid || isSubmitting) return
|
||||
|
||||
const trimmedSubject = subject.trim()
|
||||
const trimmedSummary = summary.trim()
|
||||
|
|
@ -66,14 +67,14 @@ export function PortalTicketForm() {
|
|||
toast.loading("Abrindo chamado...", { id: "portal-new-ticket" })
|
||||
try {
|
||||
const id = await createTicket({
|
||||
actorId: convexUserId as Id<"users">,
|
||||
actorId: viewerId,
|
||||
tenantId,
|
||||
subject: trimmedSubject,
|
||||
summary: trimmedSummary || undefined,
|
||||
priority: DEFAULT_PRIORITY,
|
||||
channel: "MANUAL",
|
||||
queueId: undefined,
|
||||
requesterId: convexUserId as Id<"users">,
|
||||
requesterId: viewerId,
|
||||
categoryId: categoryId as Id<"ticketCategories">,
|
||||
subcategoryId: subcategoryId as Id<"ticketSubcategories">,
|
||||
})
|
||||
|
|
@ -89,7 +90,7 @@ export function PortalTicketForm() {
|
|||
}))
|
||||
await addComment({
|
||||
ticketId: id as Id<"tickets">,
|
||||
authorId: convexUserId as Id<"users">,
|
||||
authorId: viewerId,
|
||||
visibility: "PUBLIC",
|
||||
body: htmlBody,
|
||||
attachments: typedAttachments,
|
||||
|
|
@ -186,7 +187,7 @@ export function PortalTicketForm() {
|
|||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!isFormValid || isSubmitting}
|
||||
disabled={!isFormValid || isSubmitting || !viewerId}
|
||||
className="rounded-full bg-neutral-900 px-6 text-sm font-semibold text-white hover:bg-neutral-900/90"
|
||||
>
|
||||
Registrar chamado
|
||||
|
|
|
|||
|
|
@ -16,14 +16,16 @@ import { Button } from "@/components/ui/button"
|
|||
import { PortalTicketCard } from "@/components/portal/portal-ticket-card"
|
||||
|
||||
export function PortalTicketList() {
|
||||
const { convexUserId, session } = useAuth()
|
||||
const { convexUserId, session, machineContext } = useAuth()
|
||||
|
||||
const viewerId = (convexUserId ?? machineContext?.assignedUserId ?? null) as Id<"users"> | null
|
||||
|
||||
const ticketsRaw = useQuery(
|
||||
api.tickets.list,
|
||||
convexUserId
|
||||
viewerId
|
||||
? {
|
||||
tenantId: session?.user.tenantId ?? DEFAULT_TENANT_ID,
|
||||
viewerId: convexUserId as Id<"users">,
|
||||
viewerId,
|
||||
limit: 100,
|
||||
}
|
||||
: "skip"
|
||||
|
|
@ -34,7 +36,9 @@ export function PortalTicketList() {
|
|||
return mapTicketsFromServerList((ticketsRaw as unknown[]) ?? [])
|
||||
}, [ticketsRaw])
|
||||
|
||||
if (ticketsRaw === undefined) {
|
||||
const isLoading = Boolean(viewerId && ticketsRaw === undefined)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader className="flex items-center gap-2 px-5 py-5">
|
||||
|
|
@ -48,7 +52,7 @@ export function PortalTicketList() {
|
|||
)
|
||||
}
|
||||
|
||||
if (!tickets.length) {
|
||||
if (!viewerId || !tickets.length) {
|
||||
return (
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader className="px-5 py-5">
|
||||
|
|
|
|||
|
|
@ -15,15 +15,17 @@ import { Input } from "@/components/ui/input"
|
|||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
|
||||
export function CommentTemplatesManager() {
|
||||
const { convexUserId, session } = useAuth()
|
||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
const viewerId = convexUserId as Id<"users"> | undefined
|
||||
const [activeKind, setActiveKind] = useState<"comment" | "closing">("comment")
|
||||
|
||||
const templates = useQuery(
|
||||
viewerId ? api.commentTemplates.list : "skip",
|
||||
viewerId ? { tenantId, viewerId } : "skip"
|
||||
viewerId ? { tenantId, viewerId, kind: activeKind } : "skip"
|
||||
) as
|
||||
| {
|
||||
id: Id<"commentTemplates">
|
||||
|
|
@ -33,6 +35,7 @@ export function CommentTemplatesManager() {
|
|||
updatedAt: number
|
||||
createdBy: Id<"users">
|
||||
updatedBy: Id<"users"> | null
|
||||
kind: "comment" | "closing" | string
|
||||
}[]
|
||||
| undefined
|
||||
|
||||
|
|
@ -48,6 +51,27 @@ export function CommentTemplatesManager() {
|
|||
|
||||
const orderedTemplates = useMemo(() => templates ?? [], [templates])
|
||||
|
||||
const kindLabels: Record<typeof activeKind, { title: string; description: string; placeholder: string; empty: { title: string; description: string } }> = {
|
||||
comment: {
|
||||
title: "Templates de comentário",
|
||||
description: "Mantenha respostas rápidas prontas para uso. Administradores e agentes podem criar, editar e remover templates.",
|
||||
placeholder: "Escreva a mensagem padrão...",
|
||||
empty: {
|
||||
title: "Nenhum template cadastrado",
|
||||
description: "Crie seu primeiro template de comentário usando o formulário acima.",
|
||||
},
|
||||
},
|
||||
closing: {
|
||||
title: "Templates de encerramento",
|
||||
description: "Padronize as mensagens de fechamento de tickets. Os nomes dos clientes podem ser inseridos automaticamente com {{cliente}}.",
|
||||
placeholder: "Conteúdo da mensagem de encerramento...",
|
||||
empty: {
|
||||
title: "Nenhum template de encerramento",
|
||||
description: "Cadastre mensagens padrão para encerrar tickets rapidamente.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!viewerId) return
|
||||
|
|
@ -64,7 +88,7 @@ export function CommentTemplatesManager() {
|
|||
setIsSubmitting(true)
|
||||
toast.loading("Criando template...", { id: "create-template" })
|
||||
try {
|
||||
await createTemplate({ tenantId, actorId: viewerId, title: trimmedTitle, body: sanitizedBody })
|
||||
await createTemplate({ tenantId, actorId: viewerId, title: trimmedTitle, body: sanitizedBody, kind: activeKind })
|
||||
toast.success("Template criado!", { id: "create-template" })
|
||||
setTitle("")
|
||||
setBody("")
|
||||
|
|
@ -76,7 +100,7 @@ export function CommentTemplatesManager() {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleUpdate(templateId: Id<"commentTemplates">, nextTitle: string, nextBody: string) {
|
||||
async function handleUpdate(templateId: Id<"commentTemplates">, nextTitle: string, nextBody: string, kind: "comment" | "closing" | string) {
|
||||
if (!viewerId) return
|
||||
const trimmedTitle = nextTitle.trim()
|
||||
const sanitizedBody = sanitizeEditorHtml(nextBody)
|
||||
|
|
@ -97,6 +121,7 @@ export function CommentTemplatesManager() {
|
|||
actorId: viewerId,
|
||||
title: trimmedTitle,
|
||||
body: sanitizedBody,
|
||||
kind,
|
||||
})
|
||||
toast.success("Template atualizado!", { id: toastId })
|
||||
return true
|
||||
|
|
@ -134,13 +159,24 @@ export function CommentTemplatesManager() {
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="border border-slate-200">
|
||||
<CardHeader className="flex flex-col gap-1">
|
||||
<CardTitle className="text-xl font-semibold text-neutral-900">Templates de comentário</CardTitle>
|
||||
<CardDescription className="text-sm text-neutral-600">
|
||||
Mantenha respostas rápidas prontas para uso. Administradores e agentes podem criar, editar e remover templates.
|
||||
</CardDescription>
|
||||
<CardHeader className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle className="text-xl font-semibold text-neutral-900">Templates rápidos</CardTitle>
|
||||
<CardDescription className="text-sm text-neutral-600">
|
||||
Gerencie mensagens padrão para comentários e encerramentos de tickets.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Tabs value={activeKind} onValueChange={(value) => setActiveKind(value as "comment" | "closing")} className="w-full">
|
||||
<TabsList className="h-10 w-full justify-start rounded-lg bg-slate-100 p-1">
|
||||
<TabsTrigger value="comment" className="rounded-md px-4 py-1.5 text-sm font-medium">Comentários</TabsTrigger>
|
||||
<TabsTrigger value="closing" className="rounded-md px-4 py-1.5 text-sm font-medium">Encerramentos</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="mb-4 text-sm text-neutral-600">
|
||||
{kindLabels[activeKind].description}
|
||||
</CardDescription>
|
||||
<form className="space-y-4" onSubmit={handleCreate}>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="template-title" className="text-sm font-medium text-neutral-800">
|
||||
|
|
@ -158,7 +194,7 @@ export function CommentTemplatesManager() {
|
|||
<label htmlFor="template-body" className="text-sm font-medium text-neutral-800">
|
||||
Conteúdo padrão
|
||||
</label>
|
||||
<RichTextEditor value={body} onChange={setBody} minHeight={180} placeholder="Escreva a mensagem padrão..." />
|
||||
<RichTextEditor value={body} onChange={setBody} minHeight={180} placeholder={kindLabels[activeKind].placeholder} />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
{body ? (
|
||||
|
|
@ -189,7 +225,7 @@ export function CommentTemplatesManager() {
|
|||
<IconFileText className="size-5 text-neutral-500" /> Templates cadastrados
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-neutral-600">
|
||||
Gerencie as mensagens prontas utilizadas nos comentários de tickets.
|
||||
Gerencie as mensagens prontas utilizadas nos {activeKind === "comment" ? "comentários" : "encerramentos"} de tickets.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -203,8 +239,8 @@ export function CommentTemplatesManager() {
|
|||
<EmptyMedia variant="icon">
|
||||
<IconFileText className="size-5 text-neutral-500" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhum template cadastrado</EmptyTitle>
|
||||
<EmptyDescription>Crie seu primeiro template usando o formulário acima.</EmptyDescription>
|
||||
<EmptyTitle>{kindLabels[activeKind].empty.title}</EmptyTitle>
|
||||
<EmptyDescription>{kindLabels[activeKind].empty.description}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : (
|
||||
|
|
@ -231,8 +267,9 @@ type TemplateItemProps = {
|
|||
title: string
|
||||
body: string
|
||||
updatedAt: number
|
||||
kind: "comment" | "closing" | string
|
||||
}
|
||||
onSave: (templateId: Id<"commentTemplates">, title: string, body: string) => Promise<boolean | void>
|
||||
onSave: (templateId: Id<"commentTemplates">, title: string, body: string, kind: "comment" | "closing" | string) => Promise<boolean | void>
|
||||
onDelete: (templateId: Id<"commentTemplates">) => Promise<void>
|
||||
}
|
||||
|
||||
|
|
@ -247,7 +284,7 @@ function TemplateItem({ template, onSave, onDelete }: TemplateItemProps) {
|
|||
|
||||
async function handleSave() {
|
||||
setIsSaving(true)
|
||||
const ok = await onSave(template.id, title, body)
|
||||
const ok = await onSave(template.id, title, body, template.kind ?? "comment")
|
||||
setIsSaving(false)
|
||||
if (ok !== false) {
|
||||
setIsEditing(false)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useMutation } from "convex/react"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
|
|
@ -10,17 +10,28 @@ import { Button } from "@/components/ui/button"
|
|||
import { AlertTriangle, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
||||
export function DeleteTicketDialog({ ticketId }: { ticketId: Id<"tickets"> }) {
|
||||
const router = useRouter()
|
||||
const remove = useMutation(api.tickets.remove)
|
||||
const { convexUserId, isAdmin } = useAuth()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const viewerId = useMemo(() => (isAdmin && convexUserId ? (convexUserId as Id<"users">) : null), [isAdmin, convexUserId])
|
||||
|
||||
if (!viewerId) {
|
||||
return null
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
setLoading(true)
|
||||
toast.loading("Excluindo ticket...", { id: "del" })
|
||||
try {
|
||||
await remove({ ticketId })
|
||||
if (!viewerId) {
|
||||
throw new Error("missing actor")
|
||||
}
|
||||
await remove({ ticketId, actorId: viewerId })
|
||||
toast.success("Ticket excluído.", { id: "del" })
|
||||
setOpen(false)
|
||||
router.push("/tickets")
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useMutation } from "convex/react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import type { TicketStatus } from "@/lib/schemas/ticket"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { RichTextEditor } from "@/components/ui/rich-text-editor"
|
||||
import { toast } from "sonner"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
type StatusKey = TicketStatus | "NEW" | "OPEN" | "ON_HOLD";
|
||||
|
|
@ -32,44 +37,289 @@ const itemClass = "rounded-md px-2 py-2 text-sm text-neutral-800 transition hove
|
|||
const baseBadgeClass =
|
||||
"inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 px-3 text-sm font-semibold transition hover:border-slate-300"
|
||||
|
||||
export function StatusSelect({ ticketId, value }: { ticketId: string; value: TicketStatus }) {
|
||||
type ClosingTemplate = {
|
||||
id: string
|
||||
title: string
|
||||
body: string
|
||||
}
|
||||
|
||||
const DEFAULT_PHONE_NUMBER = "(11) 4173-5368"
|
||||
|
||||
const DEFAULT_CLOSING_TEMPLATES: ClosingTemplate[] = [
|
||||
{
|
||||
id: "default-standard",
|
||||
title: "Encerramento padrão",
|
||||
body: sanitizeEditorHtml(`
|
||||
<p>Olá {{cliente}},</p>
|
||||
<p>A equipe da Raven agradece o contato. Este ticket está sendo encerrado.</p>
|
||||
<p>Se surgirem novas questões, você pode reabrir o ticket em até 7 dias ou nos contatar pelo número <strong>${DEFAULT_PHONE_NUMBER}</strong>. Obrigado.</p>
|
||||
<p>👍 👀 🙌<br />Gabriel Henrique · Raven</p>
|
||||
`),
|
||||
},
|
||||
{
|
||||
id: "default-no-contact",
|
||||
title: "Tentativa de contato sem sucesso",
|
||||
body: sanitizeEditorHtml(`
|
||||
<p>Prezado(a) {{cliente}},</p>
|
||||
<p>Realizamos uma tentativa de contato, mas não obtivemos sucesso.</p>
|
||||
<p>Por favor, retorne assim que possível para seguirmos com as verificações necessárias.</p>
|
||||
<p>Este ticket será encerrado após 3 tentativas realizadas sem sucesso.</p>
|
||||
<p>Telefone para contato: <strong>${DEFAULT_PHONE_NUMBER}</strong>.</p>
|
||||
<p>👍 👀 🙌<br />Gabriel Henrique · Raven</p>
|
||||
`),
|
||||
},
|
||||
{
|
||||
id: "default-closed-after-attempts",
|
||||
title: "Encerramento após 3 tentativas",
|
||||
body: sanitizeEditorHtml(`
|
||||
<p>Prezado(a) {{cliente}},</p>
|
||||
<p>Esse ticket está sendo encerrado pois realizamos 3 tentativas sem retorno.</p>
|
||||
<p>Você pode reabrir este ticket em até 7 dias ou entrar em contato pelo telefone <strong>${DEFAULT_PHONE_NUMBER}</strong> quando preferir.</p>
|
||||
<p>👍 👀 🙌<br />Gabriel Henrique · Raven</p>
|
||||
`),
|
||||
},
|
||||
]
|
||||
|
||||
function applyTemplatePlaceholders(html: string, customerName?: string | null) {
|
||||
const normalizedName = customerName?.trim()
|
||||
const fallback = normalizedName && normalizedName.length > 0 ? normalizedName : "cliente"
|
||||
return html.replace(/{{\s*(cliente|customer|customername|nome|nomecliente)\s*}}/gi, fallback)
|
||||
}
|
||||
|
||||
export function StatusSelect({
|
||||
ticketId,
|
||||
value,
|
||||
tenantId,
|
||||
requesterName,
|
||||
}: {
|
||||
ticketId: string
|
||||
value: TicketStatus
|
||||
tenantId: string
|
||||
requesterName?: string | null
|
||||
}) {
|
||||
const updateStatus = useMutation(api.tickets.updateStatus)
|
||||
const [status, setStatus] = useState<TicketStatus>(value)
|
||||
const { convexUserId } = useAuth()
|
||||
const actorId = (convexUserId ?? null) as Id<"users"> | null
|
||||
const [status, setStatus] = useState<TicketStatus>(value)
|
||||
const [closeDialogOpen, setCloseDialogOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setStatus(value)
|
||||
}, [value])
|
||||
|
||||
const handleStatusChange = async (selected: string) => {
|
||||
const next = selected as TicketStatus
|
||||
if (next === "RESOLVED") {
|
||||
setCloseDialogOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
const previous = status
|
||||
setStatus(next)
|
||||
toast.loading("Atualizando status...", { id: "status" })
|
||||
try {
|
||||
if (!actorId) {
|
||||
throw new Error("missing user")
|
||||
}
|
||||
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: next, actorId })
|
||||
toast.success("Status alterado para " + (statusStyles[next]?.label ?? next) + ".", { id: "status" })
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setStatus(previous)
|
||||
toast.error("Não foi possível atualizar o status.", { id: "status" })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={async (selected) => {
|
||||
const previous = status
|
||||
const next = selected as TicketStatus
|
||||
setStatus(next)
|
||||
toast.loading("Atualizando status...", { id: "status" })
|
||||
try {
|
||||
if (!convexUserId) throw new Error("missing user")
|
||||
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: next, actorId: convexUserId as Id<"users"> })
|
||||
toast.success("Status alterado para " + (statusStyles[next]?.label ?? next) + ".", { id: "status" })
|
||||
} catch {
|
||||
setStatus(previous)
|
||||
toast.error("Não foi possível atualizar o status.", { id: "status" })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={triggerClass} aria-label="Atualizar status">
|
||||
<SelectValue asChild>
|
||||
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass ?? statusStyles.PENDING.badgeClass)}>
|
||||
{statusStyles[status]?.label ?? statusStyles.PENDING.label}
|
||||
<ChevronDown className="size-3 text-current transition group-data-[state=open]:rotate-180" />
|
||||
</Badge>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option} className={itemClass}>
|
||||
{statusStyles[option].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<>
|
||||
<Select value={status} onValueChange={handleStatusChange}>
|
||||
<SelectTrigger className={triggerClass} aria-label="Atualizar status">
|
||||
<SelectValue asChild>
|
||||
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass ?? statusStyles.PENDING.badgeClass)}>
|
||||
{statusStyles[status]?.label ?? statusStyles.PENDING.label}
|
||||
<ChevronDown className="size-3 text-current transition group-data-[state=open]:rotate-180" />
|
||||
</Badge>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option} className={itemClass}>
|
||||
{statusStyles[option].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<CloseTicketDialog
|
||||
open={closeDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setCloseDialogOpen(false)
|
||||
}}
|
||||
ticketId={ticketId}
|
||||
tenantId={tenantId}
|
||||
actorId={actorId}
|
||||
requesterName={requesterName}
|
||||
onSuccess={() => {
|
||||
setStatus("RESOLVED")
|
||||
setCloseDialogOpen(false)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type CloseTicketDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
ticketId: string
|
||||
tenantId: string
|
||||
actorId: Id<"users"> | null
|
||||
requesterName?: string | null
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
function CloseTicketDialog({ open, onOpenChange, ticketId, tenantId, actorId, requesterName, onSuccess }: CloseTicketDialogProps) {
|
||||
const updateStatus = useMutation(api.tickets.updateStatus)
|
||||
const addComment = useMutation(api.tickets.addComment)
|
||||
|
||||
const closingTemplates = useQuery(
|
||||
actorId && open ? api.commentTemplates.list : "skip",
|
||||
actorId && open ? { tenantId, viewerId: actorId, kind: "closing" as const } : "skip"
|
||||
) as { id: string; title: string; body: string }[] | undefined
|
||||
|
||||
const templatesLoading = Boolean(actorId && open && closingTemplates === undefined)
|
||||
|
||||
const templates = useMemo<ClosingTemplate[]>(() => {
|
||||
if (closingTemplates && closingTemplates.length > 0) {
|
||||
return closingTemplates.map((template) => ({ id: template.id, title: template.title, body: template.body }))
|
||||
}
|
||||
return DEFAULT_CLOSING_TEMPLATES
|
||||
}, [closingTemplates])
|
||||
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string>("")
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedTemplateId(null)
|
||||
setMessage("")
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
if (templates.length > 0 && !selectedTemplateId && !message) {
|
||||
const first = templates[0]
|
||||
const hydrated = sanitizeEditorHtml(applyTemplatePlaceholders(first.body, requesterName))
|
||||
setSelectedTemplateId(first.id)
|
||||
setMessage(hydrated)
|
||||
}
|
||||
}, [open, templates, requesterName, selectedTemplateId, message])
|
||||
|
||||
const handleTemplateSelect = (template: ClosingTemplate) => {
|
||||
setSelectedTemplateId(template.id)
|
||||
const filled = sanitizeEditorHtml(applyTemplatePlaceholders(template.body, requesterName))
|
||||
setMessage(filled)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!actorId) {
|
||||
toast.error("É necessário estar autenticado para encerrar o ticket.")
|
||||
return
|
||||
}
|
||||
setIsSubmitting(true)
|
||||
toast.loading("Encerrando ticket...", { id: "close-ticket" })
|
||||
try {
|
||||
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: "RESOLVED", actorId })
|
||||
const withPlaceholders = applyTemplatePlaceholders(message, requesterName)
|
||||
const sanitized = sanitizeEditorHtml(withPlaceholders)
|
||||
const hasContent = sanitized.replace(/<[^>]*>/g, "").trim().length > 0
|
||||
if (hasContent) {
|
||||
await addComment({
|
||||
ticketId: ticketId as unknown as Id<"tickets">,
|
||||
authorId: actorId,
|
||||
visibility: "PUBLIC",
|
||||
body: sanitized,
|
||||
attachments: [],
|
||||
})
|
||||
}
|
||||
toast.success("Ticket encerrado com sucesso!", { id: "close-ticket" })
|
||||
onOpenChange(false)
|
||||
onSuccess()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Não foi possível encerrar o ticket.", { id: "close-ticket" })
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Encerrar ticket</DialogTitle>
|
||||
<DialogDescription>
|
||||
Confirme a mensagem de encerramento que será enviada ao cliente. Você pode personalizar o texto antes de concluir.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-neutral-800">Modelos rápidos</p>
|
||||
{templatesLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-600">
|
||||
<Spinner className="size-4" /> Carregando templates...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{templates.map((template) => (
|
||||
<Button
|
||||
key={template.id}
|
||||
type="button"
|
||||
variant={selectedTemplateId === template.id ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleTemplateSelect(template)}
|
||||
>
|
||||
{template.title}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-neutral-500">
|
||||
Use <code className="rounded bg-slate-100 px-1 py-0.5">{"{{cliente}}"}</code> dentro do template para inserir automaticamente o nome do solicitante.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-neutral-800">Mensagem de encerramento</p>
|
||||
<RichTextEditor
|
||||
value={message}
|
||||
onChange={setMessage}
|
||||
minHeight={220}
|
||||
placeholder="Escreva uma mensagem final para o cliente..."
|
||||
/>
|
||||
<p className="text-xs text-neutral-500">Você pode editar o conteúdo antes de enviar. Deixe em branco para encerrar sem comentário adicional.</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="flex flex-wrap justify-between gap-3 pt-4">
|
||||
<div className="text-xs text-neutral-500">
|
||||
O comentário será público e ficará registrado no histórico do ticket.
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setMessage("")
|
||||
setSelectedTemplateId(null)
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Limpar mensagem
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSubmit} disabled={isSubmitting || !actorId}>
|
||||
{isSubmitting ? <Spinner className="size-4 text-white" /> : "Encerrar ticket"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
const [localBodies, setLocalBodies] = useState<Record<string, string>>({})
|
||||
|
||||
const templateArgs = convexUserId && isStaff
|
||||
? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> }
|
||||
? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users">, kind: "comment" as const }
|
||||
: "skip"
|
||||
const templatesResult = useQuery(convexUserId && isStaff ? api.commentTemplates.list : "skip", templateArgs) as
|
||||
| { id: string; title: string; body: string }[]
|
||||
|
|
|
|||
|
|
@ -499,7 +499,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
</Badge>
|
||||
) : null}
|
||||
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
|
||||
<StatusSelect ticketId={ticket.id} value={status} />
|
||||
<StatusSelect
|
||||
ticketId={ticket.id}
|
||||
value={status}
|
||||
tenantId={ticket.tenantId}
|
||||
requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null}
|
||||
/>
|
||||
{isPlaying ? (
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -84,12 +84,24 @@ interface TicketsFiltersProps {
|
|||
onChange?: (filters: TicketFiltersState) => void
|
||||
queues?: QueueOption[]
|
||||
companies?: string[]
|
||||
initialState?: Partial<TicketFiltersState>
|
||||
}
|
||||
|
||||
const ALL_VALUE = "ALL"
|
||||
|
||||
export function TicketsFilters({ onChange, queues = [], companies = [] }: TicketsFiltersProps) {
|
||||
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
|
||||
export function TicketsFilters({ onChange, queues = [], companies = [], initialState }: TicketsFiltersProps) {
|
||||
const mergedDefaults = useMemo(
|
||||
() => ({
|
||||
...defaultTicketFilters,
|
||||
...(initialState ?? {}),
|
||||
}),
|
||||
[initialState]
|
||||
)
|
||||
const [filters, setFilters] = useState<TicketFiltersState>(mergedDefaults)
|
||||
|
||||
useEffect(() => {
|
||||
setFilters(mergedDefaults)
|
||||
}, [mergedDefaults])
|
||||
|
||||
function setPartial(partial: Partial<TicketFiltersState>) {
|
||||
setFilters((prev) => ({ ...prev, ...partial }))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
|
|
@ -12,8 +12,23 @@ import { TicketsTable } from "@/components/tickets/tickets-table"
|
|||
import { useAuth } from "@/lib/auth-client"
|
||||
import { useDefaultQueues } from "@/hooks/use-default-queues"
|
||||
|
||||
export function TicketsView() {
|
||||
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
|
||||
type TicketsViewProps = {
|
||||
initialFilters?: Partial<TicketFiltersState>
|
||||
}
|
||||
|
||||
export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
|
||||
const mergedInitialFilters = useMemo(
|
||||
() => ({
|
||||
...defaultTicketFilters,
|
||||
...(initialFilters ?? {}),
|
||||
}),
|
||||
[initialFilters]
|
||||
)
|
||||
const [filters, setFilters] = useState<TicketFiltersState>(mergedInitialFilters)
|
||||
|
||||
useEffect(() => {
|
||||
setFilters(mergedInitialFilters)
|
||||
}, [mergedInitialFilters])
|
||||
const { session, convexUserId, isStaff } = useAuth()
|
||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
|
||||
|
|
@ -73,7 +88,12 @@ export function TicketsView() {
|
|||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
||||
<TicketsFilters onChange={setFilters} queues={(queues ?? []).map((q) => q.name)} companies={companies} />
|
||||
<TicketsFilters
|
||||
onChange={setFilters}
|
||||
queues={(queues ?? []).map((q) => q.name)}
|
||||
companies={companies}
|
||||
initialState={mergedInitialFilters}
|
||||
/>
|
||||
{ticketsRaw === undefined ? (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="grid gap-3">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue