feat: enhance tickets portal and admin flows
This commit is contained in:
parent
9cdd8763b4
commit
c15f0a5b09
67 changed files with 1101 additions and 338 deletions
|
|
@ -3,7 +3,6 @@
|
|||
import { useMemo, useState } from "react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
// @ts-expect-error Convex runtime API lacks generated types
|
||||
import { api } from "@/convex/_generated/api"
|
||||
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useMemo, useState } from "react"
|
|||
import { useMutation, useQuery } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import { IconAdjustments, IconForms, IconListDetails, IconTypography } from "@tabler/icons-react"
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useMemo, useState } from "react"
|
|||
import { useMutation, useQuery } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import { IconInbox, IconHierarchy2, IconLink, IconPlus } from "@tabler/icons-react"
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useMemo, useState } from "react"
|
|||
import { useMutation, useQuery } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import { IconAlarm, IconBolt, IconTargetArrow } from "@tabler/icons-react"
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useMemo, useState } from "react"
|
|||
import { useMutation, useQuery } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import { IconUsersGroup, IconCalendarClock, IconSettings, IconUserPlus } from "@tabler/icons-react"
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ReactNode } from "react"
|
||||
import { Suspense, type ReactNode } from "react"
|
||||
|
||||
import { AppSidebar } from "@/components/app-sidebar"
|
||||
import { AuthGuard } from "@/components/auth/auth-guard"
|
||||
|
|
@ -14,7 +14,9 @@ export function AppShell({ header, children }: AppShellProps) {
|
|||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<AuthGuard />
|
||||
<Suspense fallback={null}>
|
||||
<AuthGuard />
|
||||
</Suspense>
|
||||
{header}
|
||||
<main className="flex flex-1 flex-col gap-8 bg-gradient-to-br from-background via-background to-primary/10 pb-12 pt-6">
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -2,18 +2,18 @@
|
|||
|
||||
import { MeshGradient } from "@paper-design/shaders-react"
|
||||
|
||||
export default function BackgroundPaperShadersWrapper() {
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export default function BackgroundPaperShadersWrapper({ className }: { className?: string }) {
|
||||
const speed = 1.0
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-black relative overflow-hidden">
|
||||
<div className={cn("relative h-full w-full overflow-hidden bg-black", className)}>
|
||||
<MeshGradient
|
||||
className="w-full h-full absolute inset-0"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
colors={["#000000", "#1a1a1a", "#333333", "#ffffff"]}
|
||||
speed={speed * 0.5}
|
||||
wireframe="true"
|
||||
/>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,7 +103,16 @@ export function EnergyRing({
|
|||
useFrame((state) => {
|
||||
if (mesh.current) {
|
||||
mesh.current.rotation.z = state.clock.elapsedTime
|
||||
mesh.current.material.opacity = 0.5 + Math.sin(state.clock.elapsedTime * 3) * 0.3
|
||||
const material = mesh.current.material
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach((mat) => {
|
||||
if ("opacity" in mat) {
|
||||
mat.opacity = 0.5 + Math.sin(state.clock.elapsedTime * 3) * 0.3
|
||||
}
|
||||
})
|
||||
} else if (material && "opacity" in material) {
|
||||
material.opacity = 0.5 + Math.sin(state.clock.elapsedTime * 3) * 0.3
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import * as React from "react"
|
|||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
|
||||
|
||||
import { useQuery } from "convex/react"
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
|
@ -39,17 +38,22 @@ import {
|
|||
|
||||
export const description = "Distribuição semanal de tickets por canal"
|
||||
|
||||
export function ChartAreaInteractive() {
|
||||
const isMobile = useIsMobile()
|
||||
export function ChartAreaInteractive() {
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
const isMobile = useIsMobile()
|
||||
const [timeRange, setTimeRange] = React.useState("7d")
|
||||
const { session, convexUserId } = useAuth()
|
||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isMobile) {
|
||||
setTimeRange("7d")
|
||||
}
|
||||
}, [isMobile])
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isMobile) {
|
||||
setTimeRange("7d")
|
||||
}
|
||||
}, [isMobile])
|
||||
|
||||
const report = useQuery(
|
||||
api.reports.ticketsByChannel,
|
||||
|
|
@ -72,7 +76,7 @@ export function ChartAreaInteractive() {
|
|||
)
|
||||
|
||||
const chartConfig = React.useMemo(() => {
|
||||
const entries = channels.map((channel, index) => [
|
||||
const entries = channels.map((channel: string, index: number) => [
|
||||
channel,
|
||||
{
|
||||
label: channel
|
||||
|
|
@ -87,7 +91,7 @@ export function ChartAreaInteractive() {
|
|||
|
||||
const chartData = React.useMemo(() => {
|
||||
if (!report?.points) return []
|
||||
return report.points.map((point) => {
|
||||
return report.points.map((point: { date: string; values: Record<string, number> }) => {
|
||||
const entry: Record<string, number | string> = { date: point.date }
|
||||
for (const channel of channels) {
|
||||
entry[channel] = point.values[channel] ?? 0
|
||||
|
|
@ -95,6 +99,14 @@ export function ChartAreaInteractive() {
|
|||
return entry
|
||||
})
|
||||
}, [channels, report])
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex h-[250px] items-center justify-center rounded-xl border border-dashed border-border/60 text-sm text-muted-foreground">
|
||||
Carregando gráfico...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="@container/card">
|
||||
|
|
@ -156,7 +168,7 @@ export function ChartAreaInteractive() {
|
|||
>
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
{channels.map((channel) => (
|
||||
{channels.map((channel: string) => (
|
||||
<linearGradient key={channel} id={`fill-${channel}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
|
|
@ -203,7 +215,7 @@ export function ChartAreaInteractive() {
|
|||
{channels
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((channel) => (
|
||||
.map((channel: string) => (
|
||||
<Area
|
||||
key={channel}
|
||||
dataKey={channel}
|
||||
|
|
@ -212,7 +224,11 @@ export function ChartAreaInteractive() {
|
|||
stroke={chartConfig[channel]?.color ?? "var(--chart-1)"}
|
||||
strokeWidth={2}
|
||||
stackId="a"
|
||||
name={chartConfig[channel]?.label ?? channel}
|
||||
name={
|
||||
typeof chartConfig[channel]?.label === "string"
|
||||
? (chartConfig[channel]?.label as string)
|
||||
: channel
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
|
|
@ -221,4 +237,6 @@ export function ChartAreaInteractive() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default ChartAreaInteractive
|
||||
|
|
|
|||
|
|
@ -12,19 +12,17 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
const statusLabel: Record<Ticket["status"], string> = {
|
||||
NEW: "Novo",
|
||||
OPEN: "Aberto",
|
||||
PENDING: "Pendente",
|
||||
ON_HOLD: "Em espera",
|
||||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||
PAUSED: "Pausado",
|
||||
RESOLVED: "Resolvido",
|
||||
CLOSED: "Fechado",
|
||||
}
|
||||
|
||||
const statusTone: Record<Ticket["status"], string> = {
|
||||
NEW: "bg-slate-200 text-slate-800",
|
||||
OPEN: "bg-sky-100 text-sky-700",
|
||||
PENDING: "bg-amber-100 text-amber-700",
|
||||
ON_HOLD: "bg-violet-100 text-violet-700",
|
||||
PENDING: "bg-slate-200 text-slate-800",
|
||||
AWAITING_ATTENDANCE: "bg-sky-100 text-sky-700",
|
||||
PAUSED: "bg-violet-100 text-violet-700",
|
||||
RESOLVED: "bg-emerald-100 text-emerald-700",
|
||||
CLOSED: "bg-slate-100 text-slate-600",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { format, formatDistanceToNow } from "date-fns"
|
|||
import { ptBR } from "date-fns/locale"
|
||||
import { MessageCircle } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript definitions
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
|
|
@ -23,10 +22,9 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
|||
import { sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
|
||||
|
||||
const statusLabel: Record<TicketWithDetails["status"], string> = {
|
||||
NEW: "Novo",
|
||||
OPEN: "Aberto",
|
||||
PENDING: "Pendente",
|
||||
ON_HOLD: "Em espera",
|
||||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||
PAUSED: "Pausado",
|
||||
RESOLVED: "Resolvido",
|
||||
CLOSED: "Fechado",
|
||||
}
|
||||
|
|
@ -126,7 +124,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (!convexUserId || !comment.trim()) return
|
||||
if (!convexUserId || !comment.trim() || !ticket) return
|
||||
const toastId = "portal-add-comment"
|
||||
toast.loading("Enviando comentário...", { id: toastId })
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useMemo, useState } from "react"
|
|||
import { useRouter } from "next/navigation"
|
||||
import { useMutation } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript definitions
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import { useMemo } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript definitions
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { useMemo } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
import { IconInbox, IconAlertTriangle, IconFilter } from "@tabler/icons-react"
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
|
@ -20,12 +19,11 @@ const PRIORITY_LABELS: Record<string, string> = {
|
|||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
NEW: "Novo",
|
||||
OPEN: "Em andamento",
|
||||
PENDING: "Pendente",
|
||||
ON_HOLD: "Em espera",
|
||||
RESOLVED: "Resolvido",
|
||||
CLOSED: "Encerrado",
|
||||
PENDING: "Pendentes",
|
||||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||
PAUSED: "Pausados",
|
||||
RESOLVED: "Resolvidos",
|
||||
CLOSED: "Encerrados",
|
||||
}
|
||||
|
||||
export function BacklogReport() {
|
||||
|
|
@ -38,7 +36,7 @@ export function BacklogReport() {
|
|||
|
||||
const mostCriticalPriority = useMemo(() => {
|
||||
if (!data) return null
|
||||
const entries = Object.entries(data.priorityCounts)
|
||||
const entries = Object.entries(data.priorityCounts) as Array<[string, number]>
|
||||
if (entries.length === 0) return null
|
||||
return entries.reduce((prev, current) => (current[1] > prev[1] ? current : prev))
|
||||
}, [data])
|
||||
|
|
@ -104,7 +102,7 @@ export function BacklogReport() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Object.entries(data.statusCounts).map(([status, total]) => (
|
||||
{(Object.entries(data.statusCounts) as Array<[string, number]>).map(([status, total]) => (
|
||||
<div key={status} className="rounded-xl border border-slate-200 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
{STATUS_LABELS[status] ?? status}
|
||||
|
|
@ -125,7 +123,7 @@ export function BacklogReport() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(data.priorityCounts).map(([priority, total]) => (
|
||||
{(Object.entries(data.priorityCounts) as Array<[string, number]>).map(([priority, total]) => (
|
||||
<div key={priority} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
||||
<span className="text-sm font-medium text-neutral-800">
|
||||
{PRIORITY_LABELS[priority] ?? priority}
|
||||
|
|
@ -153,7 +151,7 @@ export function BacklogReport() {
|
|||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{data.queueCounts.map((queue) => (
|
||||
{data.queueCounts.map((queue: { id: string; name: string; total: number }) => (
|
||||
<li key={queue.id} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-neutral-900">{queue.name}</span>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import { useQuery } from "convex/react"
|
||||
import { IconMoodSmile, IconStars, IconMessageCircle2 } from "@tabler/icons-react"
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
|
@ -68,7 +67,7 @@ export function CsatReport() {
|
|||
{data.recent.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500">Ainda não coletamos nenhuma avaliação.</p>
|
||||
) : (
|
||||
data.recent.map((item) => (
|
||||
data.recent.map((item: { ticketId: string; reference: number; score: number; receivedAt: number }) => (
|
||||
<div key={`${item.ticketId}-${item.receivedAt}`} className="flex items-center justify-between rounded-lg border border-slate-200 px-3 py-2 text-sm">
|
||||
<span>#{item.reference}</span>
|
||||
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
|
||||
|
|
@ -90,7 +89,7 @@ export function CsatReport() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-3">
|
||||
{data.distribution.map((entry) => (
|
||||
{data.distribution.map((entry: { score: number; total: number }) => (
|
||||
<li key={entry.score} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { useMemo } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
import { IconAlertTriangle, IconGraph, IconClockHour4 } from "@tabler/icons-react"
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
|
@ -29,7 +28,10 @@ export function SlaReport() {
|
|||
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||
)
|
||||
|
||||
const queueTotal = useMemo(() => data?.queueBreakdown.reduce((acc, queue) => acc + queue.open, 0) ?? 0, [data])
|
||||
const queueTotal = useMemo(
|
||||
() => data?.queueBreakdown.reduce((acc: number, queue: { open: number }) => acc + queue.open, 0) ?? 0,
|
||||
[data]
|
||||
)
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
|
|
@ -104,7 +106,7 @@ export function SlaReport() {
|
|||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{data.queueBreakdown.map((queue) => (
|
||||
{data.queueBreakdown.map((queue: { id: string; name: string; open: number }) => (
|
||||
<li key={queue.id} className="flex items-center justify-between gap-4 rounded-xl border border-slate-200 px-4 py-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-neutral-900">{queue.name}</span>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { useMemo } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
import { IconClockHour4, IconMessages, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useMemo, useState } from "react"
|
|||
import { useMutation, useQuery } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import { IconFileText, IconPlus, IconTrash, IconX } from "@tabler/icons-react"
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { useMutation } from "convex/react"
|
||||
// @ts-expect-error Convex runtime API lacks TS declarations until build
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { useEffect, useMemo, useState } from "react"
|
|||
import type { Doc, Id } from "@/convex/_generated/dataModel"
|
||||
import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript definitions
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { useState } from "react"
|
|||
import { useRouter } from "next/navigation"
|
||||
import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import { useState } from "react"
|
||||
import { useMutation } from "convex/react"
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import type { TicketPriority } from "@/lib/schemas/ticket"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import Link from "next/link"
|
|||
import { formatDistanceToNow } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { useQuery } from "convex/react"
|
||||
// @ts-expect-error Convex runtime API lacks TS declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
|
|
|
|||
|
|
@ -5,10 +5,9 @@ import { Badge } from "@/components/ui/badge"
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
const statusStyles: Record<TicketStatus, { label: string; className: string }> = {
|
||||
NEW: { label: "Novo", className: "border border-slate-200 bg-slate-100 text-slate-700" },
|
||||
OPEN: { label: "Aberto", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
|
||||
PENDING: { label: "Pendente", className: "border border-slate-200 bg-[#fdebd6] text-[#7b4107]" },
|
||||
ON_HOLD: { label: "Em espera", className: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" },
|
||||
PENDING: { label: "Pendente", className: "border border-slate-200 bg-slate-100 text-slate-700" },
|
||||
AWAITING_ATTENDANCE: { label: "Aguardando atendimento", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
|
||||
PAUSED: { label: "Pausado", className: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" },
|
||||
RESOLVED: { label: "Resolvido", className: "border border-slate-200 bg-[#dcf4eb] text-[#1f6a45]" },
|
||||
CLOSED: { label: "Fechado", className: "border border-slate-200 bg-slate-200 text-slate-700" },
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import { useState } from "react"
|
||||
import { useMutation } from "convex/react"
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import type { TicketStatus } from "@/lib/schemas/ticket"
|
||||
|
|
@ -13,14 +12,20 @@ import { toast } from "sonner"
|
|||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
const statusStyles: Record<TicketStatus, { label: string; badgeClass: string }> = {
|
||||
NEW: { label: "Novo", badgeClass: "bg-slate-100 text-slate-700" },
|
||||
OPEN: { label: "Aberto", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
|
||||
PENDING: { label: "Pendente", badgeClass: "bg-[#fdebd6] text-[#7b4107]" },
|
||||
ON_HOLD: { label: "Em espera", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" },
|
||||
type StatusKey = TicketStatus | "NEW" | "OPEN" | "ON_HOLD";
|
||||
|
||||
const STATUS_OPTIONS: TicketStatus[] = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED", "CLOSED"];
|
||||
|
||||
const statusStyles: Record<StatusKey, { label: string; badgeClass: string }> = {
|
||||
PENDING: { label: "Pendente", badgeClass: "bg-slate-100 text-slate-700" },
|
||||
AWAITING_ATTENDANCE: { label: "Aguardando atendimento", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
|
||||
PAUSED: { label: "Pausado", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" },
|
||||
RESOLVED: { label: "Resolvido", badgeClass: "bg-[#dcf4eb] text-[#1f6a45]" },
|
||||
CLOSED: { label: "Fechado", badgeClass: "bg-slate-200 text-slate-700" },
|
||||
}
|
||||
NEW: { label: "Pendente", badgeClass: "bg-slate-100 text-slate-700" },
|
||||
OPEN: { label: "Aguardando atendimento", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
|
||||
ON_HOLD: { label: "Pausado", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" },
|
||||
};
|
||||
|
||||
const triggerClass =
|
||||
"group inline-flex h-auto w-auto items-center justify-center rounded-full border border-transparent bg-transparent p-0 shadow-none ring-0 ring-offset-0 ring-offset-transparent focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-none hover:bg-transparent data-[state=open]:bg-transparent data-[state=open]:border-transparent data-[state=open]:shadow-none data-[state=open]:ring-0 data-[state=open]:ring-offset-0 data-[state=open]:ring-offset-transparent [&>*:last-child]:hidden"
|
||||
|
|
@ -53,14 +58,14 @@ export function StatusSelect({ ticketId, value }: { ticketId: string; value: Tic
|
|||
>
|
||||
<SelectTrigger className={triggerClass} aria-label="Atualizar status">
|
||||
<SelectValue asChild>
|
||||
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass)}>
|
||||
{statusStyles[status]?.label ?? status}
|
||||
<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">
|
||||
{(["NEW", "OPEN", "PENDING", "ON_HOLD", "RESOLVED", "CLOSED"] as const).map((option) => (
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option} className={itemClass}>
|
||||
{statusStyles[option].label}
|
||||
</SelectItem>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { ptBR } from "date-fns/locale"
|
|||
import { IconLock, IconMessage, IconFileText } from "@tabler/icons-react"
|
||||
import { FileIcon, Image as ImageIcon, PencilLine, Trash2, X } from "lucide-react"
|
||||
import { useAction, useMutation, useQuery } from "convex/react"
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "convex/react";
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript definitions
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants";
|
||||
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useQuery } from "convex/react"
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import type { TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { format, formatDistanceToNow } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { IconClock, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
|
||||
import { IconClock, IconFileDownload, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
// @ts-expect-error Convex generates JS module without TS definitions
|
||||
import { api } from "@/convex/_generated/api"
|
||||
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
|
@ -20,6 +19,9 @@ import { StatusSelect } from "@/components/tickets/status-select"
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { useTicketCategories } from "@/hooks/use-ticket-categories"
|
||||
import { useDefaultQueues } from "@/hooks/use-default-queues"
|
||||
|
||||
|
|
@ -44,6 +46,11 @@ const subtleBadgeClass =
|
|||
|
||||
const EMPTY_CATEGORY_VALUE = "__none__"
|
||||
const EMPTY_SUBCATEGORY_VALUE = "__none__"
|
||||
const PAUSE_REASONS = [
|
||||
{ value: "NO_CONTACT", label: "Falta de contato" },
|
||||
{ value: "WAITING_THIRD_PARTY", label: "Aguardando terceiro" },
|
||||
{ value: "IN_PROCEDURE", label: "Em procedimento" },
|
||||
]
|
||||
|
||||
function formatDuration(durationMs: number) {
|
||||
if (durationMs <= 0) return "0s"
|
||||
|
|
@ -104,6 +111,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
}
|
||||
)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [pauseDialogOpen, setPauseDialogOpen] = useState(false)
|
||||
const [pauseReason, setPauseReason] = useState<string>(PAUSE_REASONS[0]?.value ?? "NO_CONTACT")
|
||||
const [pauseNote, setPauseNote] = useState("")
|
||||
const [pausing, setPausing] = useState(false)
|
||||
const [exportingPdf, setExportingPdf] = useState(false)
|
||||
const selectedCategoryId = categorySelection.categoryId
|
||||
const selectedSubcategoryId = categorySelection.subcategoryId
|
||||
const dirty = useMemo(
|
||||
|
|
@ -272,6 +284,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
return () => clearInterval(interval)
|
||||
}, [workSummary?.activeSession])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pauseDialogOpen) {
|
||||
setPauseReason(PAUSE_REASONS[0]?.value ?? "NO_CONTACT")
|
||||
setPauseNote("")
|
||||
setPausing(false)
|
||||
}
|
||||
}, [pauseDialogOpen])
|
||||
|
||||
const currentSessionMs = workSummary?.activeSession ? Math.max(0, now - workSummary.activeSession.startedAt) : 0
|
||||
const totalWorkedMs = workSummary ? workSummary.totalWorkedMs + currentSessionMs : 0
|
||||
|
||||
|
|
@ -281,6 +301,74 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
[ticket.updatedAt]
|
||||
)
|
||||
|
||||
const handleStartWork = async () => {
|
||||
if (!convexUserId) return
|
||||
toast.dismiss("work")
|
||||
toast.loading("Iniciando atendimento...", { id: "work" })
|
||||
try {
|
||||
const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId 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" })
|
||||
}
|
||||
}
|
||||
|
||||
const handlePauseConfirm = async () => {
|
||||
if (!convexUserId) return
|
||||
toast.dismiss("work")
|
||||
toast.loading("Pausando atendimento...", { id: "work" })
|
||||
setPausing(true)
|
||||
try {
|
||||
const result = await pauseWork({
|
||||
ticketId: ticket.id as Id<"tickets">,
|
||||
actorId: convexUserId as Id<"users">,
|
||||
reason: pauseReason,
|
||||
note: pauseNote.trim() ? pauseNote.trim() : undefined,
|
||||
})
|
||||
if (result?.status === "already_paused") {
|
||||
toast.info("O atendimento já estava pausado", { id: "work" })
|
||||
} else {
|
||||
toast.success("Atendimento pausado", { id: "work" })
|
||||
}
|
||||
setPauseDialogOpen(false)
|
||||
} catch {
|
||||
toast.error("Não foi possível atualizar o atendimento", { id: "work" })
|
||||
} finally {
|
||||
setPausing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportPdf = useCallback(async () => {
|
||||
try {
|
||||
setExportingPdf(true)
|
||||
toast.dismiss("ticket-export")
|
||||
toast.loading("Gerando PDF...", { id: "ticket-export" })
|
||||
const response = await fetch(`/api/tickets/${ticket.id}/export/pdf`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`failed: ${response.status}`)
|
||||
}
|
||||
const blob = await response.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.download = `ticket-${ticket.reference}.pdf`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success("PDF exportado com sucesso!", { id: "ticket-export" })
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Não foi possível exportar o PDF.", { id: "ticket-export" })
|
||||
} finally {
|
||||
setExportingPdf(false)
|
||||
}
|
||||
}, [ticket.id, ticket.reference])
|
||||
|
||||
return (
|
||||
<div className={cardClass}>
|
||||
<div className="absolute right-6 top-6 flex items-center gap-3">
|
||||
|
|
@ -294,6 +382,16 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
Editar
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-semibold text-neutral-800 hover:bg-slate-50"
|
||||
onClick={handleExportPdf}
|
||||
disabled={exportingPdf}
|
||||
>
|
||||
{exportingPdf ? <Spinner className="size-3 text-neutral-700" /> : <IconFileDownload className="size-4" />}
|
||||
Exportar PDF
|
||||
</Button>
|
||||
<DeleteTicketDialog ticketId={ticket.id as Id<"tickets">} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
|
|
@ -305,28 +403,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
<Button
|
||||
size="sm"
|
||||
className={isPlaying ? pauseButtonClass : startButtonClass}
|
||||
onClick={async () => {
|
||||
onClick={() => {
|
||||
if (!convexUserId) return
|
||||
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: convexUserId 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: convexUserId 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" })
|
||||
if (isPlaying) {
|
||||
setPauseDialogOpen(true)
|
||||
} else {
|
||||
void handleStartWork()
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
@ -539,6 +621,53 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Dialog open={pauseDialogOpen} onOpenChange={setPauseDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Registrar pausa</DialogTitle>
|
||||
<DialogDescription>Informe o motivo da pausa para registrar no histórico do chamado.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Motivo</span>
|
||||
<Select value={pauseReason} onValueChange={setPauseReason}>
|
||||
<SelectTrigger className={selectTriggerClass}>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
||||
{PAUSE_REASONS.map((reason) => (
|
||||
<SelectItem key={reason.value} value={reason.value}>
|
||||
{reason.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Observações</span>
|
||||
<Textarea
|
||||
value={pauseNote}
|
||||
onChange={(event) => setPauseNote(event.target.value)}
|
||||
rows={3}
|
||||
placeholder="Adicione detalhes opcionais (visível apenas internamente)."
|
||||
className="min-h-[96px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPauseDialogOpen(false)} disabled={pausing}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
className={pauseButtonClass}
|
||||
onClick={handlePauseConfirm}
|
||||
disabled={pausing || !pauseReason}
|
||||
>
|
||||
{pausing ? <Spinner className="size-4 text-white" /> : "Registrar pausa"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { format } from "date-fns"
|
||||
import type { ComponentType } from "react"
|
||||
import type { ComponentType, ReactNode } from "react"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import {
|
||||
IconClockHour4,
|
||||
|
|
@ -119,9 +119,12 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
|||
sessionDurationMs?: number
|
||||
categoryName?: string
|
||||
subcategoryName?: string
|
||||
pauseReason?: string
|
||||
pauseReasonLabel?: string
|
||||
pauseNote?: string
|
||||
}
|
||||
|
||||
let message: string | null = null
|
||||
let message: ReactNode = null
|
||||
if (entry.type === "STATUS_CHANGED" && (payload.toLabel || payload.to)) {
|
||||
message = "Status alterado para " + (payload.toLabel || payload.to)
|
||||
}
|
||||
|
|
@ -153,8 +156,22 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
|||
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 (entry.type === "WORK_PAUSED") {
|
||||
const parts: string[] = []
|
||||
if (payload.pauseReasonLabel || payload.pauseReason) {
|
||||
parts.push(`Motivo: ${payload.pauseReasonLabel ?? payload.pauseReason}`)
|
||||
}
|
||||
if (typeof payload.sessionDurationMs === "number") {
|
||||
parts.push(`Tempo registrado: ${formatDuration(payload.sessionDurationMs)}`)
|
||||
}
|
||||
message = (
|
||||
<div className="space-y-1">
|
||||
<span>{parts.length > 0 ? parts.join(" • ") : "Atendimento pausado"}</span>
|
||||
{payload.pauseNote ? (
|
||||
<span className="block text-xs text-neutral-500">Observação: {payload.pauseNote}</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (entry.type === "CATEGORY_CHANGED") {
|
||||
if (payload.categoryName || payload.subcategoryName) {
|
||||
|
|
@ -168,9 +185,7 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
|||
if (!message) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-600">
|
||||
{message}
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-600">{message}</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { IconFilter, IconRefresh } from "@tabler/icons-react"
|
|||
import {
|
||||
ticketChannelSchema,
|
||||
ticketPrioritySchema,
|
||||
ticketStatusSchema,
|
||||
type TicketStatus,
|
||||
} from "@/lib/schemas/ticket"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
|
@ -24,17 +24,18 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
const statusOptions = ticketStatusSchema.options.map((status) => ({
|
||||
value: status,
|
||||
label: {
|
||||
NEW: "Novo",
|
||||
OPEN: "Aberto",
|
||||
PENDING: "Pendente",
|
||||
ON_HOLD: "Em espera",
|
||||
RESOLVED: "Resolvido",
|
||||
CLOSED: "Fechado",
|
||||
}[status],
|
||||
}))
|
||||
const statusOptions: Array<{ value: TicketStatus; label: string }> = [
|
||||
{ value: "PENDING", label: "Pendente" },
|
||||
{ value: "AWAITING_ATTENDANCE", label: "Aguardando atendimento" },
|
||||
{ value: "PAUSED", label: "Pausado" },
|
||||
{ value: "RESOLVED", label: "Resolvido" },
|
||||
{ value: "CLOSED", label: "Fechado" },
|
||||
]
|
||||
|
||||
const statusLabelMap = statusOptions.reduce<Record<TicketStatus, string>>((acc, option) => {
|
||||
acc[option.value] = option.label
|
||||
return acc
|
||||
}, {} as Record<TicketStatus, string>)
|
||||
|
||||
const priorityOptions = ticketPrioritySchema.options.map((priority) => ({
|
||||
value: priority,
|
||||
|
|
@ -62,10 +63,11 @@ type QueueOption = string
|
|||
|
||||
export type TicketFiltersState = {
|
||||
search: string
|
||||
status: string | null
|
||||
status: TicketStatus | null
|
||||
priority: string | null
|
||||
queue: string | null
|
||||
channel: string | null
|
||||
view: "active" | "completed"
|
||||
}
|
||||
|
||||
export const defaultTicketFilters: TicketFiltersState = {
|
||||
|
|
@ -74,6 +76,7 @@ export const defaultTicketFilters: TicketFiltersState = {
|
|||
priority: null,
|
||||
queue: null,
|
||||
channel: null,
|
||||
view: "active",
|
||||
}
|
||||
|
||||
interface TicketsFiltersProps {
|
||||
|
|
@ -97,10 +100,11 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
|
|||
|
||||
const activeFilters = useMemo(() => {
|
||||
const chips: string[] = []
|
||||
if (filters.status) chips.push(`Status: ${filters.status}`)
|
||||
if (filters.status) chips.push(`Status: ${statusLabelMap[filters.status] ?? filters.status}`)
|
||||
if (filters.priority) chips.push(`Prioridade: ${filters.priority}`)
|
||||
if (filters.queue) chips.push(`Fila: ${filters.queue}`)
|
||||
if (filters.channel) chips.push(`Canal: ${filters.channel}`)
|
||||
if (!filters.status && filters.view === "completed") chips.push("Exibindo concluídos")
|
||||
return chips
|
||||
}, [filters])
|
||||
|
||||
|
|
@ -132,6 +136,18 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
|
|||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={filters.view}
|
||||
onValueChange={(value) => setPartial({ view: value as TicketFiltersState["view"] })}
|
||||
>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Em andamento</SelectItem>
|
||||
<SelectItem value="completed">Concluídos</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
|
|
@ -150,7 +166,9 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
|
|||
</p>
|
||||
<Select
|
||||
value={filters.status ?? ALL_VALUE}
|
||||
onValueChange={(value) => setPartial({ status: value === ALL_VALUE ? null : value })}
|
||||
onValueChange={(value) =>
|
||||
setPartial({ status: value === ALL_VALUE ? null : (value as TicketStatus) })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Todos" />
|
||||
|
|
|
|||
|
|
@ -49,19 +49,17 @@ const tableRowClass =
|
|||
"group border-b border-slate-100 text-sm transition-colors hover:bg-slate-100/70 last:border-none"
|
||||
|
||||
const statusLabel: Record<TicketStatus, string> = {
|
||||
NEW: "Novo",
|
||||
OPEN: "Aberto",
|
||||
PENDING: "Pendente",
|
||||
ON_HOLD: "Em espera",
|
||||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||
PAUSED: "Pausado",
|
||||
RESOLVED: "Resolvido",
|
||||
CLOSED: "Fechado",
|
||||
}
|
||||
|
||||
const statusTone: Record<TicketStatus, string> = {
|
||||
NEW: "text-slate-700",
|
||||
OPEN: "text-sky-700",
|
||||
PENDING: "text-amber-700",
|
||||
ON_HOLD: "text-violet-700",
|
||||
PENDING: "text-slate-700",
|
||||
AWAITING_ATTENDANCE: "text-sky-700",
|
||||
PAUSED: "text-violet-700",
|
||||
RESOLVED: "text-emerald-700",
|
||||
CLOSED: "text-slate-600",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript definitions
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
|
|
@ -42,9 +41,23 @@ export function TicketsView() {
|
|||
const tickets = useMemo(() => mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]), [ticketsRaw])
|
||||
|
||||
const filteredTickets = useMemo(() => {
|
||||
if (!filters.queue) return tickets
|
||||
return tickets.filter((t: Ticket) => t.queue === filters.queue)
|
||||
}, [tickets, filters.queue])
|
||||
const completedStatuses = new Set<Ticket["status"]>(["RESOLVED", "CLOSED"])
|
||||
let working = tickets
|
||||
|
||||
if (!filters.status) {
|
||||
if (filters.view === "active") {
|
||||
working = working.filter((t) => !completedStatuses.has(t.status))
|
||||
} else if (filters.view === "completed") {
|
||||
working = working.filter((t) => completedStatuses.has(t.status))
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.queue) {
|
||||
working = working.filter((t) => t.queue === filters.queue)
|
||||
}
|
||||
|
||||
return working
|
||||
}, [tickets, filters.queue, filters.status, filters.view])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
||||
|
|
|
|||
|
|
@ -16,20 +16,17 @@ const DotOrbit = dynamic(
|
|||
function ShaderVisual() {
|
||||
return (
|
||||
<div className="absolute inset-0">
|
||||
<MeshGradient
|
||||
className="absolute inset-0"
|
||||
colors={["#020202", "#04131f", "#062534", "#0b3947"]}
|
||||
speed={0.8}
|
||||
backgroundColor="#020202"
|
||||
wireframe="true"
|
||||
/>
|
||||
<MeshGradient className="absolute inset-0" colors={["#020202", "#04131f", "#062534", "#0b3947"]} speed={0.8} />
|
||||
<div className="absolute inset-0 opacity-70">
|
||||
<DotOrbit
|
||||
className="h-full w-full"
|
||||
dotColor="#0f172a"
|
||||
orbitColor="#155e75"
|
||||
colors={["#0f172a", "#155e75", "#22d3ee"]}
|
||||
colorBack="#020617"
|
||||
speed={1.4}
|
||||
intensity={1.2}
|
||||
size={0.9}
|
||||
sizeRange={0.4}
|
||||
spreading={1.0}
|
||||
stepsPerColor={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useAction } from "convex/react";
|
||||
// @ts-expect-error Convex generates runtime API without TS metadata
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
|
|||
|
|
@ -28,19 +28,10 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||
style: baseStyle,
|
||||
classNames: {
|
||||
icon: "size-[14px] text-white [&>svg]:size-[14px] [&>svg]:text-white",
|
||||
actionButton: "bg-white text-black border border-black rounded-lg",
|
||||
cancelButton: "bg-transparent text-white border border-white/40 rounded-lg",
|
||||
},
|
||||
descriptionClassName: "text-white/80",
|
||||
actionButtonClassName: "bg-white text-black border border-black rounded-lg",
|
||||
cancelButtonClassName: "bg-transparent text-white border border-white/40 rounded-lg",
|
||||
iconTheme: {
|
||||
primary: "#ffffff",
|
||||
secondary: "#000000",
|
||||
},
|
||||
success: { className: baseClass, style: baseStyle },
|
||||
error: { className: baseClass, style: baseStyle },
|
||||
info: { className: baseClass, style: baseStyle },
|
||||
warning: { className: baseClass, style: baseStyle },
|
||||
loading: { className: baseClass, style: baseStyle },
|
||||
}}
|
||||
style={
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue