chore: sync staging

This commit is contained in:
Esdras Renan 2025-11-10 01:57:45 -03:00
parent c5ddd54a3e
commit 561b19cf66
610 changed files with 105285 additions and 1206 deletions

View file

@ -1,9 +1,12 @@
"use client"
import { useMemo, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { api } from "@/convex/_generated/api"
import { format, formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { History as HistoryIcon, Layers3, ListTree, PlusCircle, RefreshCcw } from "lucide-react"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"
@ -22,11 +25,38 @@ import {
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { PaginationNext, PaginationPrevious } from "@/components/ui/pagination"
type TabValue = "categories" | "subcategories" | "history"
type DeleteState<T extends "category" | "subcategory"> =
| { type: T; targetId: string; reason: string }
| null
type FlatSubcategory = TicketSubcategory & {
categoryName: string
categoryOrder: number
createdAt?: number
updatedAt?: number
}
type HistoryEntry = {
id: string
type: "category" | "subcategory"
action: "create" | "update"
name: string
categoryName?: string
timestamp: number
}
export function CategoriesManager() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
@ -42,6 +72,16 @@ export function CategoriesManager() {
const createSubcategory = useMutation(api.categories.createSubcategory)
const updateSubcategory = useMutation(api.categories.updateSubcategory)
const deleteSubcategory = useMutation(api.categories.deleteSubcategory)
const [activeTab, setActiveTab] = useState<TabValue>("categories")
const [categorySort, setCategorySort] = useState<"order" | "name" | "recent">("order")
const [categoryPage, setCategoryPage] = useState(1)
const [subcategorySort, setSubcategorySort] = useState<"category" | "name" | "recent">("category")
const [subcategoryPage, setSubcategoryPage] = useState(1)
const [historyFilter, setHistoryFilter] = useState<"all" | "category" | "subcategory">("all")
const [historyPage, setHistoryPage] = useState(1)
const CATEGORY_PAGE_SIZE = 6
const SUBCATEGORY_PAGE_SIZE = 10
const HISTORY_PAGE_SIZE = 8
const isCreatingCategory = useMemo(
() => categoryName.trim().length < 2,
@ -194,133 +234,556 @@ export function CategoriesManager() {
}
}
const sortedCategories = useMemo(() => {
if (!categories) return []
const copy = [...categories]
if (categorySort === "name") {
copy.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
} else if (categorySort === "recent") {
copy.sort(
(a, b) =>
(b.updatedAt ?? b.createdAt ?? 0) -
(a.updatedAt ?? a.createdAt ?? 0)
)
} else {
copy.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
}
return copy
}, [categories, categorySort])
useEffect(() => {
setCategoryPage(1)
}, [categorySort])
const categoryTotalPages = Math.max(
1,
Math.ceil(Math.max(sortedCategories.length, 1) / CATEGORY_PAGE_SIZE)
)
useEffect(() => {
setCategoryPage((current) => Math.min(current, categoryTotalPages))
}, [categoryTotalPages])
const paginatedCategories = useMemo(
() =>
sortedCategories.slice(
(categoryPage - 1) * CATEGORY_PAGE_SIZE,
categoryPage * CATEGORY_PAGE_SIZE
),
[sortedCategories, categoryPage]
)
const flatSubcategories = useMemo<FlatSubcategory[]>(() => {
if (!categories) return []
return categories.flatMap((category) =>
(category.secondary ?? []).map((subcategory) => ({
...subcategory,
categoryId: category.id,
categoryName: category.name,
categoryOrder: category.order ?? 0,
createdAt: subcategory.createdAt ?? category.createdAt,
updatedAt: subcategory.updatedAt ?? category.updatedAt,
}))
)
}, [categories])
const sortedSubcategories = useMemo(() => {
const copy = [...flatSubcategories]
if (subcategorySort === "name") {
copy.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
} else if (subcategorySort === "recent") {
copy.sort(
(a, b) =>
(b.updatedAt ?? b.createdAt ?? 0) -
(a.updatedAt ?? a.createdAt ?? 0)
)
} else {
copy.sort((a, b) => {
if (a.categoryName === b.categoryName) {
return a.name.localeCompare(b.name, "pt-BR")
}
return a.categoryName.localeCompare(b.categoryName, "pt-BR")
})
}
return copy
}, [flatSubcategories, subcategorySort])
useEffect(() => {
setSubcategoryPage(1)
}, [subcategorySort])
const subcategoryTotalPages = Math.max(
1,
Math.ceil(Math.max(sortedSubcategories.length, 1) / SUBCATEGORY_PAGE_SIZE)
)
useEffect(() => {
setSubcategoryPage((current) => Math.min(current, subcategoryTotalPages))
}, [subcategoryTotalPages])
const paginatedSubcategories = useMemo(
() =>
sortedSubcategories.slice(
(subcategoryPage - 1) * SUBCATEGORY_PAGE_SIZE,
subcategoryPage * SUBCATEGORY_PAGE_SIZE
),
[sortedSubcategories, subcategoryPage]
)
const historyEntries = useMemo<HistoryEntry[]>(() => {
if (!categories) return []
const items: HistoryEntry[] = []
for (const category of categories) {
if (category.createdAt) {
items.push({
id: `${category.id}-created`,
type: "category",
action: "create",
name: category.name,
timestamp: category.createdAt,
})
}
if (category.updatedAt && category.updatedAt > (category.createdAt ?? 0)) {
items.push({
id: `${category.id}-updated`,
type: "category",
action: "update",
name: category.name,
timestamp: category.updatedAt,
})
}
for (const sub of category.secondary ?? []) {
const subCreated = sub.createdAt ?? category.createdAt
const subUpdated = sub.updatedAt ?? subCreated
if (subCreated) {
items.push({
id: `${sub.id}-created`,
type: "subcategory",
action: "create",
name: sub.name,
categoryName: category.name,
timestamp: subCreated,
})
}
if (subUpdated && subUpdated > (subCreated ?? 0)) {
items.push({
id: `${sub.id}-updated`,
type: "subcategory",
action: "update",
name: sub.name,
categoryName: category.name,
timestamp: subUpdated,
})
}
}
}
return items.sort((a, b) => b.timestamp - a.timestamp)
}, [categories])
const filteredHistory = useMemo(
() =>
historyFilter === "all"
? historyEntries
: historyEntries.filter((entry) => entry.type === historyFilter),
[historyEntries, historyFilter]
)
useEffect(() => {
setHistoryPage(1)
}, [historyFilter])
const historyTotalPages = Math.max(
1,
Math.ceil(Math.max(filteredHistory.length, 1) / HISTORY_PAGE_SIZE)
)
useEffect(() => {
setHistoryPage((current) => Math.min(current, historyTotalPages))
}, [historyTotalPages])
const paginatedHistory = useMemo(
() =>
filteredHistory.slice(
(historyPage - 1) * HISTORY_PAGE_SIZE,
historyPage * HISTORY_PAGE_SIZE
),
[filteredHistory, historyPage]
)
const pendingDelete = deleteState
const isDisabled = !convexUserId
const viewerId = convexUserId as Id<"users"> | null
const focusCategory = (categoryId: string) => {
setActiveTab("categories")
if (typeof window === "undefined") return
window.requestAnimationFrame(() => {
const target = document.getElementById(`category-${categoryId}`)
target?.scrollIntoView({ behavior: "smooth", block: "start" })
})
}
const totalCategories = sortedCategories.length
const totalSubcategories = sortedSubcategories.length
const totalHistoryRecords = filteredHistory.length
const isLoading = categories === undefined
return (
<div className="space-y-6">
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="space-y-1">
<h3 className="text-lg font-semibold text-neutral-900">Categorias</h3>
<p className="text-sm text-neutral-600">
Organize a classificação primária e secundária utilizada nos tickets. Todas as alterações entram em vigor
imediatamente para novos atendimentos.
</p>
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="space-y-3 rounded-xl border border-dashed border-slate-200 bg-white/80 p-4">
<div className="space-y-2">
<Label htmlFor="category-name">Nome da categoria</Label>
<Input
id="category-name"
value={categoryName}
onChange={(event) => setCategoryName(event.target.value)}
placeholder="Ex.: Incidentes"
disabled={isDisabled}
/>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as TabValue)} className="space-y-6">
<TabsList className="flex w-full flex-wrap gap-2 rounded-2xl bg-slate-100 p-1">
<TabsTrigger value="categories" className="flex-1 min-w-[140px]">
<Layers3 className="size-4" />
Categorias
</TabsTrigger>
<TabsTrigger value="subcategories" className="flex-1 min-w-[140px]">
<ListTree className="size-4" />
Subcategorias
</TabsTrigger>
<TabsTrigger value="history" className="flex-1 min-w-[140px]">
<HistoryIcon className="size-4" />
Histórico
</TabsTrigger>
</TabsList>
<TabsContent value="categories" className="space-y-6">
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="space-y-1">
<h3 className="text-lg font-semibold text-neutral-900">Categorias</h3>
<p className="text-sm text-neutral-600">
Organize a classificação primária e secundária utilizada nos tickets. Todas as alterações entram em vigor
imediatamente para novos atendimentos.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="category-description">Descrição (opcional)</Label>
<Textarea
id="category-description"
value={categoryDescription}
onChange={(event) => setCategoryDescription(event.target.value)}
placeholder="Contextualize quando usar esta categoria"
rows={3}
disabled={isDisabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subcategory-name">Subcategorias (opcional)</Label>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 sm:flex-row">
<div className="mt-4 grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="space-y-3 rounded-xl border border-dashed border-slate-200 bg-white/80 p-4">
<div className="space-y-2">
<Label htmlFor="category-name">Nome da categoria</Label>
<Input
id="subcategory-name"
value={subcategoryDraft}
onChange={(event) => setSubcategoryDraft(event.target.value)}
placeholder="Ex.: Lentidão"
id="category-name"
value={categoryName}
onChange={(event) => setCategoryName(event.target.value)}
placeholder="Ex.: Incidentes"
disabled={isDisabled}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
addSubcategory()
}
}}
/>
<Button
type="button"
variant="outline"
onClick={addSubcategory}
disabled={isDisabled || subcategoryDraft.trim().length < 2}
className="shrink-0"
>
Adicionar subcategoria
</Button>
</div>
{subcategoryList.length ? (
<div className="flex flex-wrap gap-2">
{subcategoryList.map((item) => (
<div
key={item}
className="group inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-100 px-3 py-1 text-xs text-neutral-700"
<div className="space-y-2">
<Label htmlFor="category-description">Descrição (opcional)</Label>
<Textarea
id="category-description"
value={categoryDescription}
onChange={(event) => setCategoryDescription(event.target.value)}
placeholder="Contextualize quando usar esta categoria"
rows={3}
disabled={isDisabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subcategory-name">Subcategorias (opcional)</Label>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 sm:flex-row">
<Input
id="subcategory-name"
value={subcategoryDraft}
onChange={(event) => setSubcategoryDraft(event.target.value)}
placeholder="Ex.: Lentidão"
disabled={isDisabled}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
addSubcategory()
}
}}
/>
<Button
type="button"
variant="outline"
onClick={addSubcategory}
disabled={isDisabled || subcategoryDraft.trim().length < 2}
className="shrink-0"
>
<span>{item}</span>
<button
type="button"
onClick={() => removeSubcategory(item)}
className="rounded-full p-1 text-neutral-500 transition hover:bg-white hover:text-neutral-900"
disabled={isDisabled}
>
×
</button>
Adicionar subcategoria
</Button>
</div>
{subcategoryList.length ? (
<div className="flex flex-wrap gap-2">
{subcategoryList.map((item) => (
<div
key={item}
className="group inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-100 px-3 py-1 text-xs text-neutral-700"
>
<span>{item}</span>
<button
type="button"
onClick={() => removeSubcategory(item)}
className="rounded-full p-1 text-neutral-500 transition hover:bg-white hover:text-neutral-900"
disabled={isDisabled}
>
×
</button>
</div>
))}
</div>
))}
) : null}
</div>
) : null}
</div>
<Button
onClick={handleCreateCategory}
disabled={isDisabled || isCreatingCategory}
className="w-full sm:w-auto"
>
Adicionar categoria
</Button>
</div>
<div className="space-y-3 rounded-xl border border-slate-200 bg-slate-50/60 p-4 text-sm text-neutral-600">
<p className="font-medium text-neutral-800">Boas práticas</p>
<ul className="list-disc space-y-1 pl-4">
<li>Mantenha nomes concisos e fáceis de entender.</li>
<li>Use a descrição para orientar a equipe sobre quando aplicar cada categoria.</li>
<li>Subcategorias devem ser específicas e mutuamente exclusivas.</li>
</ul>
</div>
</div>
<Button
onClick={handleCreateCategory}
disabled={isDisabled || isCreatingCategory}
className="w-full sm:w-auto"
>
Adicionar categoria
</Button>
</div>
<div className="space-y-3 rounded-xl border border-slate-200 bg-slate-50/60 p-4 text-sm text-neutral-600">
<p className="font-medium text-neutral-800">Boas práticas</p>
<ul className="list-disc space-y-1 pl-4">
<li>Mantenha nomes concisos e fáceis de entender.</li>
<li>Use a descrição para orientar a equipe sobre quando aplicar cada categoria.</li>
<li>Subcategorias devem ser específicas e mutuamente exclusivas.</li>
</ul>
</div>
</div>
</section>
</section>
<div className="space-y-4">
{categories?.length ? (
categories.map((category) => (
<CategoryItem
key={category.id}
category={category}
onUpdate={handleUpdateCategory}
onDelete={() => setDeleteState({ type: "category", targetId: category.id, reason: "" })}
onCreateSubcategory={handleCreateSubcategory}
onUpdateSubcategory={handleUpdateSubcategory}
onDeleteSubcategory={(subcategoryId) =>
setDeleteState({ type: "subcategory", targetId: subcategoryId, reason: "" })
}
disabled={isDisabled}
/>
))
) : (
<div className="rounded-xl border border-dashed border-slate-200 bg-white/60 p-6 text-center text-sm text-neutral-600">
Nenhuma categoria cadastrada ainda.
</div>
)}
</div>
<section className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h4 className="text-base font-semibold text-neutral-900">Categorias cadastradas</h4>
<p className="text-sm text-neutral-600">
Reordene ou edite categorias existentes. Ordenação afeta a apresentação no formulário de tickets.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="rounded-full px-3 py-1 text-xs">
{totalCategories} categorias
</Badge>
<Select value={categorySort} onValueChange={(value) => setCategorySort(value as typeof categorySort)}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Ordenar por" />
</SelectTrigger>
<SelectContent>
<SelectItem value="order">Ordem manual</SelectItem>
<SelectItem value="name">Nome (A-Z)</SelectItem>
<SelectItem value="recent">Atualizadas recentemente</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{isLoading ? (
<div className="rounded-2xl border border-dashed border-slate-200 bg-white/60 p-6 text-center text-sm text-neutral-600">
Carregando categorias...
</div>
) : paginatedCategories.length ? (
<div className="space-y-4">
{paginatedCategories.map((category) => (
<CategoryItem
key={category.id}
containerId={`category-${category.id}`}
category={category}
onUpdate={handleUpdateCategory}
onDelete={() => setDeleteState({ type: "category", targetId: category.id, reason: "" })}
onCreateSubcategory={handleCreateSubcategory}
onUpdateSubcategory={handleUpdateSubcategory}
onDeleteSubcategory={(subcategoryId) =>
setDeleteState({ type: "subcategory", targetId: subcategoryId, reason: "" })
}
disabled={isDisabled}
/>
))}
</div>
) : (
<div className="rounded-2xl border border-dashed border-slate-200 bg-white/60 p-6 text-center text-sm text-neutral-600">
Nenhuma categoria cadastrada ainda.
</div>
)}
{!isLoading && (
<Pager
page={categoryPage}
totalPages={categoryTotalPages}
onPrev={() => setCategoryPage((current) => Math.max(1, current - 1))}
onNext={() => setCategoryPage((current) => Math.min(categoryTotalPages, current + 1))}
/>
)}
</section>
</TabsContent>
<TabsContent value="subcategories" className="space-y-6">
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h4 className="text-base font-semibold text-neutral-900">Subcategorias consolidadas</h4>
<p className="text-sm text-neutral-600">
Visualize todas as subcategorias ordenadas por categoria ou atualização recente.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="rounded-full px-3 py-1 text-xs">
{totalSubcategories} subcategorias
</Badge>
<Select
value={subcategorySort}
onValueChange={(value) => setSubcategorySort(value as typeof subcategorySort)}
>
<SelectTrigger className="w-[220px]">
<SelectValue placeholder="Ordenar por" />
</SelectTrigger>
<SelectContent>
<SelectItem value="category">Categoria</SelectItem>
<SelectItem value="name">Nome (A-Z)</SelectItem>
<SelectItem value="recent">Atualizadas recentemente</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{isLoading ? (
<div className="mt-4 rounded-2xl border border-dashed border-slate-200 bg-white/60 p-6 text-center text-sm text-neutral-600">
Carregando subcategorias...
</div>
) : paginatedSubcategories.length ? (
<div className="mt-4 space-y-3">
{paginatedSubcategories.map((subcategory) => (
<div
key={subcategory.id}
className="rounded-2xl border border-slate-200 bg-slate-50/60 p-4 shadow-sm"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-neutral-900">{subcategory.name}</p>
<p className="text-xs text-neutral-500">Categoria: {subcategory.categoryName}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{subcategory.updatedAt ? (
<Badge variant="outline" className="rounded-full px-2 py-1 text-xs text-neutral-600">
Atualizado{" "}
{formatDistanceToNow(new Date(subcategory.updatedAt), {
addSuffix: true,
locale: ptBR,
})}
</Badge>
) : null}
<Button
size="sm"
variant="ghost"
onClick={() => subcategory.categoryId && focusCategory(subcategory.categoryId)}
>
Gerenciar categoria
</Button>
</div>
</div>
<p className="mt-2 text-xs text-neutral-500">
Criado em{" "}
{subcategory.createdAt
? format(new Date(subcategory.createdAt), "dd/MM/yyyy HH:mm", { locale: ptBR })
: "—"}
</p>
</div>
))}
</div>
) : (
<div className="mt-4 rounded-2xl border border-dashed border-slate-200 bg-white/60 p-6 text-center text-sm text-neutral-600">
Nenhuma subcategoria cadastrada.
</div>
)}
{!isLoading && (
<Pager
page={subcategoryPage}
totalPages={subcategoryTotalPages}
onPrev={() => setSubcategoryPage((current) => Math.max(1, current - 1))}
onNext={() => setSubcategoryPage((current) => Math.min(subcategoryTotalPages, current + 1))}
/>
)}
</section>
</TabsContent>
<TabsContent value="history" className="space-y-6">
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h4 className="text-base font-semibold text-neutral-900">Histórico de mudanças</h4>
<p className="text-sm text-neutral-600">
Registro consolidado de criações e atualizações das categorias e subcategorias.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="rounded-full px-3 py-1 text-xs">
{totalHistoryRecords} eventos
</Badge>
<Select value={historyFilter} onValueChange={(value) => setHistoryFilter(value as typeof historyFilter)}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Filtrar por" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as mudanças</SelectItem>
<SelectItem value="category">Somente categorias</SelectItem>
<SelectItem value="subcategory">Somente subcategorias</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{isLoading ? (
<div className="mt-4 rounded-2xl border border-dashed border-slate-200 bg-white/60 p-6 text-center text-sm text-neutral-600">
Carregando histórico...
</div>
) : paginatedHistory.length ? (
<div className="mt-4 space-y-3">
{paginatedHistory.map((entry) => {
const isCreate = entry.action === "create"
const Icon = isCreate ? PlusCircle : RefreshCcw
return (
<div
key={entry.id}
className="flex flex-wrap items-start justify-between gap-3 rounded-2xl border border-slate-200 bg-slate-50/60 p-4 shadow-sm"
>
<div className="flex items-start gap-3">
<span className="inline-flex size-9 items-center justify-center rounded-full bg-primary/10 text-primary">
<Icon className="size-4" />
</span>
<div>
<p className="text-sm font-semibold text-neutral-900">{entry.name}</p>
<p className="text-xs text-neutral-500">
{entry.type === "subcategory"
? `Subcategoria em ${entry.categoryName ?? "—"}`
: "Categoria principal"}
</p>
<p className="text-xs text-neutral-500">
{isCreate ? "Registrada" : "Atualizada"}{" "}
{formatDistanceToNow(new Date(entry.timestamp), {
addSuffix: true,
locale: ptBR,
})}
</p>
</div>
</div>
<div className="text-right">
<p className="text-xs text-neutral-500">
{format(new Date(entry.timestamp), "dd/MM/yyyy HH:mm", { locale: ptBR })}
</p>
<Badge
variant={isCreate ? "secondary" : "outline"}
className="mt-1 rounded-full px-2 py-0.5 text-xs"
>
{isCreate ? "Criação" : "Atualização"}
</Badge>
</div>
</div>
)
})}
</div>
) : (
<div className="mt-4 rounded-2xl border border-dashed border-slate-200 bg-white/60 p-6 text-center text-sm text-neutral-600">
Nenhuma alteração registrada até o momento.
</div>
)}
{!isLoading && (
<Pager
page={historyPage}
totalPages={historyTotalPages}
onPrev={() => setHistoryPage((current) => Math.max(1, current - 1))}
onNext={() => setHistoryPage((current) => Math.min(historyTotalPages, current + 1))}
/>
)}
</section>
</TabsContent>
</Tabs>
<Dialog
open={Boolean(pendingDelete)}
@ -381,6 +844,7 @@ export function CategoriesManager() {
interface CategoryItemProps {
category: TicketCategory
disabled?: boolean
containerId?: string
onUpdate: (category: TicketCategory, next: { name: string; description: string }) => Promise<void>
onDelete: () => void
onCreateSubcategory: (categoryId: string, payload: { name: string }) => Promise<void>
@ -391,6 +855,7 @@ interface CategoryItemProps {
function CategoryItem({
category,
disabled,
containerId,
onUpdate,
onDelete,
onCreateSubcategory,
@ -402,6 +867,9 @@ function CategoryItem({
const [description, setDescription] = useState(category.description ?? "")
const [subcategoryDraft, setSubcategoryDraft] = useState("")
const hasSubcategories = category.secondary.length > 0
const updatedLabel = category.updatedAt
? formatDistanceToNow(new Date(category.updatedAt), { addSuffix: true, locale: ptBR })
: null
async function handleSave() {
await onUpdate(category, { name, description })
@ -409,7 +877,7 @@ function CategoryItem({
}
return (
<div className="space-y-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<div id={containerId} className="space-y-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
{isEditing ? (
@ -432,7 +900,17 @@ function CategoryItem({
</>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{typeof category.order === "number" ? (
<Badge variant="outline" className="rounded-full border-slate-200 px-3 py-1 text-xs text-neutral-600">
#{(category.order ?? 0) + 1}
</Badge>
) : null}
{updatedLabel ? (
<Badge variant="outline" className="rounded-full border-slate-200 px-3 py-1 text-xs text-neutral-600">
Atualizado {updatedLabel}
</Badge>
) : null}
{hasSubcategories ? (
<Badge variant="outline" className="rounded-full border-slate-200 px-3 py-1 text-xs text-neutral-600">
{category.secondary.length} subcategorias
@ -554,6 +1032,29 @@ function SubcategoryItem({ subcategory, disabled, onUpdate, onDelete }: Subcateg
)
}
type PagerProps = {
page: number
totalPages: number
onPrev: () => void
onNext: () => void
}
function Pager({ page, totalPages, onPrev, onNext }: PagerProps) {
const normalizedTotal = Math.max(totalPages, 1)
const normalizedPage = Math.min(Math.max(page, 1), normalizedTotal)
return (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-neutral-600">
<span>
Página {normalizedPage} de {normalizedTotal}
</span>
<div className="flex items-center gap-2">
<PaginationPrevious disabled={normalizedPage <= 1} onClick={onPrev} />
<PaginationNext disabled={normalizedPage >= normalizedTotal} onClick={onNext} />
</div>
</div>
)
}
type RuleFormState = {
responseValue: string
responseUnit: "minutes" | "hours" | "days"

View file

@ -1796,105 +1796,116 @@ export function AdminDevicesOverview({ tenantId, initialCompanyFilterSlug = "all
</div>
</CardHeader>
<CardContent className="overflow-hidden">
<div className="mb-3 flex flex-wrap items-center gap-2">
<div className="min-w-[220px] flex-1">
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Buscar hostname, e-mail, MAC, serial..." />
<div className="mb-3 flex flex-col gap-3">
<div className="flex flex-wrap gap-3">
<label className="flex min-w-[260px] flex-1 flex-col gap-1 text-xs font-semibold uppercase tracking-wide text-neutral-500">
Buscar hostname, e-mail, MAC, serial...
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Digite para filtrar" />
</label>
<label className="flex w-auto min-w-[170px] max-w-[220px] flex-col gap-1 text-xs font-semibold uppercase tracking-wide text-neutral-500">
Todos status
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="justify-between text-sm">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos status</SelectItem>
<SelectItem value="online">Online</SelectItem>
<SelectItem value="offline">Offline</SelectItem>
<SelectItem value="stale">Sem sinal</SelectItem>
<SelectItem value="unknown">Desconhecido</SelectItem>
</SelectContent>
</Select>
</label>
<label className="flex w-auto min-w-[180px] max-w-[240px] flex-col gap-1 text-xs font-semibold uppercase tracking-wide text-neutral-500">
Todos os tipos
<Select value={deviceTypeFilter} onValueChange={setDeviceTypeFilter}>
<SelectTrigger className="justify-between text-sm">
<SelectValue placeholder="Tipo de dispositivo" />
</SelectTrigger>
<SelectContent>
{DEVICE_TYPE_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="min-w-36">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos status</SelectItem>
<SelectItem value="online">Online</SelectItem>
<SelectItem value="offline">Offline</SelectItem>
<SelectItem value="stale">Sem sinal</SelectItem>
<SelectItem value="unknown">Desconhecido</SelectItem>
</SelectContent>
</Select>
<Select value={deviceTypeFilter} onValueChange={setDeviceTypeFilter}>
<SelectTrigger className="min-w-40">
<SelectValue placeholder="Tipo de dispositivo" />
</SelectTrigger>
<SelectContent>
{DEVICE_TYPE_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Popover open={isCompanyPopoverOpen} onOpenChange={setIsCompanyPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="min-w-56 justify-between">
{(() => {
if (companyFilterSlug === "all") return "Todas empresas"
const found = companyOptions.find((c) => c.slug === companyFilterSlug)
return found?.name ?? companyFilterSlug
})()}
<span className="ml-2 text-slate-400"></span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-2" align="start">
<div className="space-y-2">
<Input
value={companySearch}
onChange={(e) => setCompanySearch(e.target.value)}
placeholder="Buscar empresa..."
/>
<div className="max-h-64 overflow-auto rounded-md border border-slate-200">
<button
type="button"
onClick={() => {
setCompanyFilterSlug("all")
setCompanySearch("")
setIsCompanyPopoverOpen(false)
}}
className="block w-full px-3 py-2 text-left text-sm hover:bg-slate-100"
>
Todas empresas
</button>
{companyOptions
.filter((c) => c.name.toLowerCase().includes(companySearch.toLowerCase()))
.map((c) => (
<button
key={c.slug}
type="button"
onClick={() => {
setCompanyFilterSlug(c.slug)
setCompanySearch("")
setIsCompanyPopoverOpen(false)
}}
className="block w-full px-3 py-2 text-left text-sm hover:bg-slate-100"
>
{c.name}
</button>
))}
<div className="flex flex-wrap items-center gap-3">
<Popover open={isCompanyPopoverOpen} onOpenChange={setIsCompanyPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="min-w-56 justify-between">
{(() => {
if (companyFilterSlug === "all") return "Todas empresas"
const found = companyOptions.find((c) => c.slug === companyFilterSlug)
return found?.name ?? companyFilterSlug
})()}
<span className="ml-2 text-slate-400"></span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-2" align="start">
<div className="space-y-2">
<Input
value={companySearch}
onChange={(e) => setCompanySearch(e.target.value)}
placeholder="Buscar empresa..."
/>
<div className="max-h-64 overflow-auto rounded-md border border-slate-200">
<button
type="button"
onClick={() => {
setCompanyFilterSlug("all")
setCompanySearch("")
setIsCompanyPopoverOpen(false)
}}
className="block w-full px-3 py-2 text-left text-sm hover:bg-slate-100"
>
Todas empresas
</button>
{companyOptions
.filter((c) => c.name.toLowerCase().includes(companySearch.toLowerCase()))
.map((c) => (
<button
key={c.slug}
type="button"
onClick={() => {
setCompanyFilterSlug(c.slug)
setCompanySearch("")
setIsCompanyPopoverOpen(false)
}}
className="block w-full px-3 py-2 text-left text-sm hover:bg-slate-100"
>
{c.name}
</button>
))}
</div>
</div>
</div>
</PopoverContent>
</Popover>
<label className="inline-flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50/80 px-3 py-1.5 text-sm">
<Checkbox checked={onlyAlerts} onCheckedChange={(v) => setOnlyAlerts(Boolean(v))} />
<span>Somente com alertas</span>
</label>
<Button
variant="outline"
onClick={() => {
setQ("")
setStatusFilter("all")
setCompanyFilterSlug("all")
setCompanySearch("")
setOnlyAlerts(false)
setIsCompanyPopoverOpen(false)
}}
>
Limpar
</Button>
<Button size="sm" variant="outline" className="gap-2" onClick={handleOpenExportDialog}>
<Download className="size-4" />
Exportar XLSX
</Button>
</PopoverContent>
</Popover>
<label className="inline-flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50/80 px-3 py-1.5 text-sm">
<Checkbox checked={onlyAlerts} onCheckedChange={(v) => setOnlyAlerts(Boolean(v))} />
<span>Somente com alertas</span>
</label>
<Button
variant="outline"
onClick={() => {
setQ("")
setStatusFilter("all")
setCompanyFilterSlug("all")
setCompanySearch("")
setOnlyAlerts(false)
setIsCompanyPopoverOpen(false)
}}
>
Limpar
</Button>
<Button size="sm" variant="outline" className="gap-2" onClick={handleOpenExportDialog}>
<Download className="size-4" />
Exportar XLSX
</Button>
</div>
</div>
{isLoading ? (
<LoadingState />

View file

@ -6,14 +6,16 @@ import { AppSidebar } from "@/components/app-sidebar"
import { AuthGuard } from "@/components/auth/auth-guard"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { Skeleton } from "@/components/ui/skeleton"
import { GlobalQuickActions } from "@/components/global-quick-actions"
import { useAuth } from "@/lib/auth-client"
interface AppShellProps {
header: ReactNode
children: ReactNode
}
export function AppShell({ header, children }: AppShellProps) {
interface AppShellProps {
header: ReactNode
children: ReactNode
showQuickActions?: boolean
}
export function AppShell({ header, children, showQuickActions = true }: AppShellProps) {
const { isLoading } = useAuth()
const [hydrated, setHydrated] = useState(false)
@ -44,6 +46,9 @@ export function AppShell({ header, children }: AppShellProps) {
) : (
header
)}
{showQuickActions ? (
renderSkeleton ? <QuickActionsSkeleton /> : <GlobalQuickActions />
) : null}
<main className="flex flex-1 flex-col gap-8 bg-gradient-to-br from-background via-background to-primary/10 pb-12 pt-6">
{renderSkeleton ? (
<div className="space-y-6">
@ -65,3 +70,15 @@ export function AppShell({ header, children }: AppShellProps) {
</SidebarProvider>
)
}
function QuickActionsSkeleton() {
return (
<div className="border-b border-border/60 bg-muted/30 px-4 py-3 lg:px-8">
<div className="flex gap-3 overflow-x-hidden">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={`qa-skeleton-${index}`} className="h-12 w-48 rounded-2xl" />
))}
</div>
</div>
)
}

View file

@ -2,27 +2,28 @@
import * as React from "react"
import {
LayoutDashboard,
LayoutTemplate,
LifeBuoy,
Ticket,
PlayCircle,
BarChart3,
TrendingUp,
PanelsTopLeft,
UserCog,
AlertTriangle,
Building,
Building2,
Waypoints,
Clock4,
Timer,
MonitorCog,
UserPlus,
ChevronDown,
ShieldCheck,
Users,
Layers3,
CalendarDays,
ChevronDown,
Clock4,
Gauge,
LayoutDashboard,
Layers3,
LayoutTemplate,
LifeBuoy,
MonitorCog,
PlayCircle,
ShieldAlert,
ShieldCheck,
Ticket,
Timer,
TrendingUp,
UserCog,
UserPlus,
Users,
Waypoints,
} from "lucide-react"
import { usePathname } from "next/navigation"
import Link from "next/link"
@ -77,11 +78,14 @@ const navigation: NavigationGroup[] = [
url: "/tickets",
icon: Ticket,
requiredRole: "staff",
children: [{ title: "Resolvidos", url: "/tickets/resolved", icon: ShieldCheck, requiredRole: "staff" }],
children: [
{ title: "Todos os tickets", url: "/tickets", requiredRole: "staff" },
{ title: "Resolvidos", url: "/tickets/resolved", icon: ShieldCheck, requiredRole: "staff" },
],
},
{ title: "Visualizações", url: "/views", icon: PanelsTopLeft, requiredRole: "staff" },
{ title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" },
{ title: "Agenda", url: "/agenda", icon: CalendarDays, requiredRole: "staff" },
{ title: "Dispositivos", url: "/admin/devices", icon: MonitorCog, requiredRole: "admin" },
],
},
{
@ -91,8 +95,8 @@ const navigation: NavigationGroup[] = [
{ title: "Painéis customizados", url: "/dashboards", icon: LayoutTemplate, requiredRole: "staff" },
{ title: "SLA & Produtividade", url: "/reports/sla", icon: TrendingUp, requiredRole: "staff" },
{ title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" },
{ title: "Backlog", url: "/reports/backlog", icon: BarChart3, requiredRole: "staff" },
{ title: "Clientes atendidos", url: "/reports/company", icon: Building2, requiredRole: "staff" },
{ title: "Backlog", url: "/reports/backlog", icon: Gauge, requiredRole: "staff" },
{ title: "Clientes", url: "/reports/company", icon: Building2, requiredRole: "staff" },
{ title: "Categorias", url: "/reports/categories", icon: Layers3, requiredRole: "staff" },
{ title: "Horas", url: "/reports/hours", icon: Clock4, requiredRole: "staff" },
],
@ -102,26 +106,39 @@ const navigation: NavigationGroup[] = [
requiredRole: "admin",
items: [
{
title: "Administração",
title: "Cadastros",
url: "/admin",
icon: UserPlus,
requiredRole: "admin",
exact: true,
children: [
{ title: "Equipe", url: "/admin", icon: LayoutDashboard, requiredRole: "admin", exact: true },
{ title: "Empresas", url: "/admin/companies", icon: Building, requiredRole: "admin" },
{ title: "Usuários", url: "/admin/users", icon: Users, requiredRole: "admin" },
],
},
{ title: "Filas", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
{ title: "Times & papéis", url: "/admin/teams", icon: UserCog, requiredRole: "admin", hidden: true },
{
title: "Empresas & clientes",
url: "/admin/companies",
icon: Building,
title: "Orquestração",
url: "/admin/channels",
icon: Layers3,
requiredRole: "admin",
children: [
{ title: "Filas", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
{ title: "Times & papéis", url: "/admin/teams", icon: UserCog, requiredRole: "admin" },
],
},
{ title: "Usuários", url: "/admin/users", icon: Users, requiredRole: "admin" },
{ title: "Dispositivos", url: "/admin/devices", icon: MonitorCog, requiredRole: "admin" },
{ title: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" },
{
title: "Integração & SLAs",
url: "/admin/devices",
icon: MonitorCog,
requiredRole: "admin",
children: [
{ title: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" },
{ title: "Alertas", url: "/admin/alerts", icon: ShieldAlert, requiredRole: "admin" },
],
},
{ title: "Incidentes", url: "/incidentes", icon: AlertTriangle, requiredRole: "admin" },
],
},
// Removido grupo "Conta" (Configurações) para evitar redundância com o menu do usuário no rodapé
]
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
@ -245,32 +262,56 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const childItems = item.children.filter((child) => !child.hidden && canAccess(child.requiredRole))
const isExpanded = expanded.has(item.title)
const isChildActive = childItems.some((child) => isActive(child))
const parentActive = isActive(item) || isChildActive
const parentActive = item.title === "Tickets" ? isActive(item) || isChildActive : isChildActive
const isToggleOnly = item.title !== "Tickets"
return (
<React.Fragment key={item.title}>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={parentActive}>
<Link href={item.url} className={cn("gap-2", "relative pr-7") }>
{isToggleOnly ? (
<button
type="button"
className={cn(
"relative flex w-full cursor-pointer select-none items-center gap-2 rounded-lg border border-transparent px-2 py-1 text-left transition hover:bg-sidebar-accent/70",
isExpanded && "bg-sidebar-accent/60"
)}
onClick={() => toggleExpanded(item.title)}
>
{item.icon ? <item.icon className="size-4" /> : null}
<span className="flex-1">{item.title}</span>
<span className="flex-1 text-sm font-medium text-foreground">{item.title}</span>
<span
role="button"
aria-label={isExpanded ? "Recolher submenu" : "Expandir submenu"}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggleExpanded(item.title)
}}
aria-hidden="true"
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",
"inline-flex h-6 w-6 items-center justify-center rounded-md text-neutral-500 transition",
isExpanded && "rotate-180"
)}
>
<ChevronDown className="size-3" />
</span>
</Link>
</SidebarMenuButton>
</button>
) : (
<SidebarMenuButton asChild isActive={parentActive}>
<Link 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>
</Link>
</SidebarMenuButton>
)}
</SidebarMenuItem>
{isExpanded
? childItems.map((child) => (

View file

@ -24,7 +24,7 @@ import {
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import { formatDateDM, formatDateDMY } from "@/lib/utils"
import { cn, formatDateDM, formatDateDMY } from "@/lib/utils"
import { Skeleton } from "@/components/ui/skeleton"
import {
Select,
@ -39,15 +39,38 @@ import {
ToggleGroupItem,
} from "@/components/ui/toggle-group"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
export const description = "Distribuição semanal de tickets por canal"
export function ChartAreaInteractive() {
export const description = "Distribuição semanal de tickets por canal"
type ChartRange = "7d" | "30d" | "90d"
type ChartAreaInteractiveProps = {
range?: ChartRange
onRangeChange?: (value: ChartRange) => void
companyId?: string
onCompanyChange?: (value: string) => void
hideControls?: boolean
title?: string
description?: React.ReactNode
className?: string
}
export function ChartAreaInteractive({
range,
onRangeChange,
companyId,
onCompanyChange,
hideControls = false,
title = "Entrada de tickets por canal",
description: descriptionOverride,
className,
}: ChartAreaInteractiveProps = {}) {
const [mounted, setMounted] = React.useState(false)
const isMobile = useIsMobile()
const [timeRange, setTimeRange] = React.useState("7d")
const [internalRange, setInternalRange] = React.useState<ChartRange>(range ?? "7d")
const timeRange = range ?? internalRange
// Persistir seleção de empresa globalmente
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
const [internalCompanyId, setInternalCompanyId] = usePersistentCompanyFilter("all")
const selectedCompanyId = companyId ?? internalCompanyId
const { session, convexUserId, isStaff } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
@ -56,16 +79,36 @@ export function ChartAreaInteractive() {
}, [])
React.useEffect(() => {
if (isMobile) {
setTimeRange("7d")
if (!range && isMobile) {
setInternalRange("7d")
}
}, [isMobile])
}, [isMobile, range])
const handleRangeChange = (value: ChartRange) => {
if (!value) return
onRangeChange?.(value)
if (!range) {
setInternalRange(value)
}
}
const handleCompanyChange = (value: string) => {
onCompanyChange?.(value)
if (!companyId) {
setInternalCompanyId(value)
}
}
const reportsEnabled = Boolean(isStaff && convexUserId)
const report = useQuery(
api.reports.ticketsByChannel,
reportsEnabled
? ({ tenantId, viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) })
? ({
tenantId,
viewerId: convexUserId as Id<"users">,
range: timeRange,
companyId: selectedCompanyId === "all" ? undefined : (selectedCompanyId as Id<"companies">),
})
: "skip"
)
const companies = useQuery(
@ -133,60 +176,67 @@ export function ChartAreaInteractive() {
)
}
return (
<Card className="@container/card">
<CardHeader>
<CardTitle>Entrada de tickets por canal</CardTitle>
<CardDescription>
<span className="hidden @[540px]/card:block">
Distribuição dos canais nos últimos {timeRange.replace("d", " dias")}
</span>
<span className="@[540px]/card:hidden">Período: {timeRange}</span>
</CardDescription>
const defaultDescription = (
<>
<span className="hidden @[540px]/card:block">
Distribuição dos canais nos últimos {timeRange.replace("d", " dias")}
</span>
<span className="@[540px]/card:hidden">Período: {timeRange}</span>
</>
)
return (
<Card className={cn("@container/card", className)}>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{descriptionOverride ?? defaultDescription}</CardDescription>
<CardAction>
<div className="flex w-full flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:justify-end sm:gap-2">
{/* Company picker with search */}
<SearchableCombobox
value={companyId}
onValueChange={(next) => setCompanyId(next ?? "all")}
options={companyOptions}
placeholder="Todas as empresas"
className="w-full min-w-56 sm:w-64"
/>
{/* Desktop time range toggles */}
<ToggleGroup
type="single"
value={timeRange}
onValueChange={setTimeRange}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
>
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
</ToggleGroup>
{/* Mobile time range select */}
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger
className="flex w-full min-w-40 @[767px]/card:hidden"
size="sm"
aria-label="Selecionar período"
>
<SelectValue placeholder="Selecionar período" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="90d" className="rounded-lg">Últimos 90 dias</SelectItem>
<SelectItem value="30d" className="rounded-lg">Últimos 30 dias</SelectItem>
<SelectItem value="7d" className="rounded-lg">Últimos 7 dias</SelectItem>
</SelectContent>
</Select>
{/* Export button aligned at the end */}
<Button asChild size="sm" variant="outline" className="sm:ml-1">
{!hideControls ? (
<>
<SearchableCombobox
value={selectedCompanyId}
onValueChange={(next) => handleCompanyChange(next ?? "all")}
options={companyOptions}
placeholder="Todas as empresas"
className="w-full min-w-56 sm:w-64"
/>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={(value) => handleRangeChange((value as ChartRange) ?? timeRange)}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
>
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
</ToggleGroup>
<Select value={timeRange} onValueChange={(value) => handleRangeChange(value as ChartRange)}>
<SelectTrigger
className="flex w-full min-w-40 @[767px]/card:hidden"
size="sm"
aria-label="Selecionar período"
>
<SelectValue placeholder="Selecionar período" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="90d" className="rounded-lg">
Últimos 90 dias
</SelectItem>
<SelectItem value="30d" className="rounded-lg">
Últimos 30 dias
</SelectItem>
<SelectItem value="7d" className="rounded-lg">
Últimos 7 dias
</SelectItem>
</SelectContent>
</Select>
</>
) : null}
<Button asChild size="sm" variant="outline" className={cn(!hideControls && "sm:ml-1")}>
<a
href={`/api/reports/tickets-by-channel.xlsx?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`}
href={`/api/reports/tickets-by-channel.xlsx?range=${timeRange}${selectedCompanyId !== "all" ? `&companyId=${selectedCompanyId}` : ""}`}
download
>
Exportar XLSX
@ -194,8 +244,8 @@ export function ChartAreaInteractive() {
</Button>
</div>
</CardAction>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
{report === undefined ? (
<div className="flex h-[250px] items-center justify-center">
<Skeleton className="h-24 w-full" />

View file

@ -1,188 +0,0 @@
"use client"
import * as React from "react"
import { Pie, PieChart, LabelList, Bar, BarChart, CartesianGrid, XAxis } from "recharts"
import { useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardAction } from "@/components/ui/card"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
export function ViewsCharts() {
return (
<div className="grid gap-6 lg:grid-cols-2">
<BacklogPriorityPie />
<QueuesOpenBar />
</div>
)
}
function BacklogPriorityPie() {
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
const [timeRange, setTimeRange] = React.useState("30d")
const { session, convexUserId, isStaff } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const data = useQuery(
api.reports.backlogOverview,
isStaff && convexUserId
? ({ tenantId, viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) })
: "skip"
) as { priorityCounts: Record<string, number> } | undefined
const companies = useQuery(
api.companies.list,
isStaff && convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ id: Id<"companies">; name: string }> | undefined
const companyOptions = React.useMemo<SearchableComboboxOption[]>(() => {
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
if (!companies || companies.length === 0) {
return base
}
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
return [
base[0],
...sorted.map((company) => ({
value: company.id,
label: company.name,
})),
]
}, [companies])
if (!data) return <Skeleton className="h-[300px] w-full" />
const PRIORITY_LABELS: Record<string, string> = { LOW: "Baixa", MEDIUM: "Média", HIGH: "Alta", URGENT: "Crítica" }
const keys = ["LOW", "MEDIUM", "HIGH", "URGENT"]
const fills = {
LOW: "var(--chart-1)",
MEDIUM: "var(--chart-2)",
HIGH: "var(--chart-3)",
URGENT: "var(--chart-4)",
} as const
const chartData = keys
.map((k) => ({ name: PRIORITY_LABELS[k] ?? k, value: data.priorityCounts?.[k] ?? 0, fill: fills[k as keyof typeof fills] }))
.filter((d) => d.value > 0)
const chartConfig: Record<string, { label: string }> = { value: { label: "Tickets" } }
return (
<Card className="flex flex-col">
<CardHeader className="pb-0">
<CardTitle>Backlog por prioridade</CardTitle>
<CardDescription>Distribuição de tickets no período</CardDescription>
<CardAction>
<div className="flex flex-wrap items-center justify-end gap-2 md:gap-3">
<SearchableCombobox
value={companyId}
onValueChange={(next) => setCompanyId(next ?? "all")}
options={companyOptions}
placeholder="Todas as empresas"
className="w-full min-w-56 md:w-56"
/>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Período" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="90d">Últimos 90 dias</SelectItem>
<SelectItem value="30d">Últimos 30 dias</SelectItem>
<SelectItem value="7d">Últimos 7 dias</SelectItem>
</SelectContent>
</Select>
</div>
</CardAction>
</CardHeader>
<CardContent className="flex-1 pb-0">
{chartData.length === 0 ? (
<div className="flex h-[250px] items-center justify-center rounded-xl border border-dashed border-border/60 text-sm text-muted-foreground">
Sem dados no período selecionado.
</div>
) : (
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square max-h-[250px] [&_.recharts-text]:fill-background"
>
<PieChart>
<ChartTooltip content={<ChartTooltipContent nameKey="value" hideLabel />} />
<Pie data={chartData} dataKey="value" nameKey="name">
<LabelList dataKey="name" className="fill-background" stroke="none" fontSize={12} />
</Pie>
</PieChart>
</ChartContainer>
)}
</CardContent>
</Card>
)
}
function QueuesOpenBar() {
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
const { session, convexUserId, isStaff } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const data = useQuery(
api.reports.slaOverview,
isStaff && convexUserId
? ({ tenantId, viewerId: convexUserId as Id<"users">, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) })
: "skip"
) as { queueBreakdown: { id: string; name: string; open: number }[] } | undefined
const companies = useQuery(
api.companies.list,
isStaff && convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ id: Id<"companies">; name: string }> | undefined
const companyOptions = React.useMemo<SearchableComboboxOption[]>(() => {
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
if (!companies || companies.length === 0) {
return base
}
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
return [
base[0],
...sorted.map((company) => ({
value: company.id,
label: company.name,
})),
]
}, [companies])
if (!data) return <Skeleton className="h-[300px] w-full" />
const chartData = (data.queueBreakdown ?? []).map((q) => ({ queue: q.name, open: q.open }))
const chartConfig: Record<string, { label: string }> = { open: { label: "Abertos" } }
return (
<Card>
<CardHeader>
<CardTitle>Filas com maior volume aberto</CardTitle>
<CardDescription>Distribuição atual por fila</CardDescription>
<CardAction>
<SearchableCombobox
value={companyId}
onValueChange={(next) => setCompanyId(next ?? "all")}
options={companyOptions}
placeholder="Todas as empresas"
className="w-full min-w-56 md:w-56"
/>
</CardAction>
</CardHeader>
<CardContent>
{chartData.length === 0 ? (
<div className="flex h-[250px] items-center justify-center rounded-xl border border-dashed border-border/60 text-sm text-muted-foreground">
Sem filas com tickets abertos.
</div>
) : (
<ChartContainer config={chartConfig} className="aspect-auto h-[250px] w-full">
<BarChart accessibilityLayer data={chartData} margin={{ left: 12, right: 12 }}>
<CartesianGrid vertical={false} />
<XAxis dataKey="queue" tickLine={false} axisLine={false} tickMargin={8} minTickGap={32} />
<ChartTooltip content={<ChartTooltipContent nameKey="open" hideLabel />} />
<Bar dataKey="open" radius={6} />
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,122 @@
"use client"
import Link from "next/link"
import { useMemo } from "react"
import { useQuery } from "convex/react"
import { AlertTriangle, BellRing } from "lucide-react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { cn } from "@/lib/utils"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Button } from "@/components/ui/button"
type AlertEntry = {
_id: string
companyName: string
usagePct: number
threshold: number
range: string
recipients: string[]
createdAt: number
}
export function DashboardAlertsPanel() {
const { convexUserId, isAdmin, session } = useAuth()
const tenantId = session?.user?.tenantId ?? DEFAULT_TENANT_ID
const enabled = Boolean(convexUserId && isAdmin)
const alerts = useQuery(
api.alerts.list,
enabled
? {
tenantId,
viewerId: convexUserId as Id<"users">,
limit: 5,
}
: "skip"
) as AlertEntry[] | undefined
const items = useMemo(() => (alerts ?? []).slice(0, 5), [alerts])
return (
<Card className="rounded-3xl border border-border/60 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between gap-4">
<div>
<CardTitle className="text-base font-semibold">Alertas automáticos</CardTitle>
<CardDescription>Últimos avisos disparados para gestores</CardDescription>
</div>
{isAdmin ? (
<Button variant="outline" size="sm" asChild>
<Link href="/admin/alerts">Configurar alertas</Link>
</Button>
) : null}
</CardHeader>
<CardContent className="space-y-3">
{!enabled ? (
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-border/60 bg-muted/40 px-4 py-6 text-center text-sm text-muted-foreground">
<BellRing className="size-6" />
<p>Apenas administradores podem visualizar os alertas disparados.</p>
</div>
) : alerts === undefined ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={`alerts-skeleton-${index}`} className="h-16 w-full rounded-2xl" />
))}
</div>
) : items.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/60 bg-muted/40 px-4 py-6 text-center text-sm text-muted-foreground">
Nenhum alerta disparado recentemente. Configure thresholds personalizados para acompanhar consumo de horas e SLAs.
</div>
) : (
<div className="space-y-2">
{items.map((entry) => (
<AlertRow key={entry._id} entry={entry} />
))}
</div>
)}
</CardContent>
</Card>
)
}
function AlertRow({ entry }: { entry: AlertEntry }) {
const exceeded = entry.usagePct >= entry.threshold
const severityClass = exceeded ? "text-amber-600 bg-amber-50" : "text-emerald-600 bg-emerald-50"
return (
<div className="flex flex-col gap-2 rounded-2xl border border-border/60 bg-white/90 px-4 py-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<div className={cn("rounded-full p-1", severityClass)}>
<AlertTriangle className="size-4" />
</div>
<p className="text-sm font-semibold text-neutral-800">{entry.companyName}</p>
</div>
<Badge variant="outline" className="rounded-full border-border/60 px-3 py-1 text-xs">{entry.range}</Badge>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-neutral-500">
<span>
Uso {entry.usagePct.toFixed(1)}% Limite {entry.threshold}%
</span>
{entry.recipients.length ? (
<span className="truncate">
Destinatários: <strong>{entry.recipients.slice(0, 2).join(", ")}</strong>
{entry.recipients.length > 2 ? ` +${entry.recipients.length - 2}` : ""}
</span>
) : null}
<span>
{new Date(entry.createdAt).toLocaleString("pt-BR", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
</div>
)
}

View file

@ -0,0 +1,483 @@
"use client"
import { type ReactNode } from "react"
import Link from "next/link"
import { useQuery } from "convex/react"
import { IconClockHour4, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { cn, formatDateDM, formatDateDMY } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { Skeleton } from "@/components/ui/skeleton"
type DashboardOverview = {
newTickets?: { last24h: number; previous24h: number; trendPercentage: number | null }
inProgress?: { current: number; previousSnapshot: number; trendPercentage: number | null }
firstResponse?: { averageMinutes: number | null; deltaMinutes: number | null; responsesCount: number }
awaitingAction?: { total: number; atRisk: number }
resolution?: { resolvedLast7d: number; previousResolved: number; rate: number | null; deltaPercentage: number | null }
}
type QueueTrendResponse = {
rangeDays: number
queues: Array<{
id: string
name: string
openedTotal: number
resolvedTotal: number
series: Array<{ date: string; opened: number; resolved: number }>
}>
}
const queueSparkConfig = {
opened: { label: "Novos", color: "var(--chart-1)" },
resolved: { label: "Resolvidos", color: "var(--chart-2)" },
}
export function DashboardHero() {
const { session, convexUserId, isStaff } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const dashboardEnabled = Boolean(isStaff && convexUserId)
const overview = useQuery(
api.reports.dashboardOverview,
dashboardEnabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as DashboardOverview | undefined
const newTicketsTrend = overview?.newTickets
? {
delta: overview.newTickets.trendPercentage,
label:
overview.newTickets.trendPercentage === null
? "Sem comparativo"
: `${overview.newTickets.trendPercentage >= 0 ? "+" : ""}${overview.newTickets.trendPercentage.toFixed(1)}% vs. 24h anteriores`,
}
: null
const inProgressTrend = overview?.inProgress
? {
delta: overview.inProgress.trendPercentage,
label:
overview.inProgress.trendPercentage === null
? "Sem histórico"
: `${overview.inProgress.trendPercentage >= 0 ? "+" : ""}${overview.inProgress.trendPercentage.toFixed(1)}% vs. última medição`,
}
: null
const responseDelta = overview?.firstResponse
? (() => {
const delta = overview.firstResponse.deltaMinutes
if (delta === null) return { delta, label: "Sem comparação" }
return { delta, label: `${delta > 0 ? "+" : ""}${delta.toFixed(1)} min` }
})()
: null
const resolutionInfo = overview?.resolution
? {
delta: overview.resolution.deltaPercentage ?? null,
label:
overview.resolution.deltaPercentage === null
? "Sem histórico"
: `${overview.resolution.deltaPercentage >= 0 ? "+" : ""}${overview.resolution.deltaPercentage.toFixed(1)}%`,
rateLabel:
overview.resolution.rate !== null
? `${overview.resolution.rate.toFixed(1)}% dos tickets resolvidos`
: "Taxa indisponível",
}
: { delta: null, label: "Sem histórico", rateLabel: "Taxa indisponível" }
const newTicketsFooter = (() => {
if (!newTicketsTrend) return "Aguardando dados"
if (newTicketsTrend.delta === null) return "Sem comparativo recente"
return newTicketsTrend.delta >= 0 ? "Acima das 24h anteriores" : "Abaixo das 24h anteriores"
})()
const inProgressFooter = (() => {
if (!inProgressTrend) return "Monitoramento aguardando histórico"
if (inProgressTrend.delta === null) return "Sem histórico recente"
return inProgressTrend.delta >= 0 ? "Acima da última medição" : "Abaixo da última medição"
})()
const responseFooter = (() => {
if (!responseDelta) return "Sem dados suficientes para comparação"
if (responseDelta.delta === null) return "Sem comparação recente"
return responseDelta.delta <= 0 ? "Melhor que a última medição" : "Pior que a última medição"
})()
const resolutionFooter = resolutionInfo?.rateLabel ?? "Taxa indisponível no momento."
return (
<div className="space-y-6 px-4 lg:px-6">
<div className="grid gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<Card className="@container/card rounded-3xl border border-border/60 bg-gradient-to-br from-white via-white to-primary/5 p-5 shadow-sm">
<CardHeader className="gap-3 px-0">
<CardDescription>Novos tickets (24h)</CardDescription>
<CardTitle className="text-3xl font-semibold text-neutral-900 tabular-nums">
{overview?.newTickets ? overview.newTickets.last24h : <Skeleton className="h-8 w-20" />}
</CardTitle>
{newTicketsTrend ? (
<CardAction className="px-0">
<Badge
variant="outline"
className={cn(
"gap-1 rounded-full border-border/60 px-3 py-1 text-xs font-semibold",
newTicketsTrend.delta === null
? "text-neutral-500"
: newTicketsTrend.delta < 0
? "text-emerald-600"
: "text-amber-600"
)}
>
{newTicketsTrend.delta !== null && newTicketsTrend.delta < 0 ? (
<IconTrendingDown className="size-3.5" />
) : (
<IconTrendingUp className="size-3.5" />
)}
{newTicketsTrend.label}
</Badge>
</CardAction>
) : null}
</CardHeader>
<CardFooter className="flex-col items-start gap-1 px-0 text-sm text-neutral-500">
<span>{newTicketsFooter}</span>
<span className="text-xs text-neutral-400">Base: entradas registradas nas últimas 24h.</span>
</CardFooter>
</Card>
<Card className="@container/card rounded-3xl border border-border/60 bg-gradient-to-br from-white via-white to-primary/5 p-5 shadow-sm">
<CardHeader className="gap-3 px-0">
<CardDescription>Em atendimento</CardDescription>
<CardTitle className="text-3xl font-semibold text-neutral-900 tabular-nums">
{overview?.inProgress ? overview.inProgress.current : <Skeleton className="h-8 w-14" />}
</CardTitle>
{inProgressTrend ? (
<CardAction className="px-0">
<Badge
variant="outline"
className={cn(
"gap-1 rounded-full border-border/60 px-3 py-1 text-xs font-semibold",
inProgressTrend.delta === null
? "text-neutral-500"
: inProgressTrend.delta > 0
? "text-amber-600"
: "text-emerald-600"
)}
>
{inProgressTrend.delta !== null && inProgressTrend.delta > 0 ? (
<IconTrendingUp className="size-3.5" />
) : (
<IconTrendingDown className="size-3.5" />
)}
{inProgressTrend.label}
</Badge>
</CardAction>
) : null}
</CardHeader>
<CardFooter className="flex-col items-start gap-1 px-0 text-sm text-neutral-500">
<span>{inProgressFooter}</span>
<span className="text-xs text-neutral-400">Base: tickets ativos em SLA.</span>
</CardFooter>
</Card>
<Card className="@container/card rounded-3xl border border-border/60 bg-gradient-to-br from-white via-white to-primary/5 p-5 shadow-sm">
<CardHeader className="gap-3 px-0">
<CardDescription>Tempo médio (1ª resposta)</CardDescription>
<CardTitle className="text-3xl font-semibold text-neutral-900 tabular-nums">
{overview?.firstResponse ? (
overview.firstResponse.averageMinutes !== null ? (
`${overview.firstResponse.averageMinutes.toFixed(1)} min`
) : (
"—"
)
) : (
<Skeleton className="h-8 w-28" />
)}
</CardTitle>
{responseDelta ? (
<CardAction className="px-0">
<Badge
variant="outline"
className={cn(
"gap-1 rounded-full border-border/60 px-3 py-1 text-xs font-semibold",
responseDelta.delta === null
? "text-neutral-500"
: responseDelta.delta > 0
? "text-amber-600"
: "text-emerald-600"
)}
>
{responseDelta.delta !== null && responseDelta.delta > 0 ? (
<IconTrendingUp className="size-3.5" />
) : (
<IconTrendingDown className="size-3.5" />
)}
{responseDelta.label}
</Badge>
</CardAction>
) : null}
</CardHeader>
<CardFooter className="flex-col items-start gap-1 px-0 text-sm text-neutral-500">
<span>{responseFooter}</span>
<span className="text-xs text-neutral-400">Média móvel dos últimos 7 dias.</span>
</CardFooter>
</Card>
<Card className="@container/card rounded-3xl border border-border/60 bg-gradient-to-br from-white via-white to-primary/5 p-5 shadow-sm">
<CardHeader className="gap-3 px-0">
<CardDescription>Resolvidos (7 dias)</CardDescription>
<CardTitle className="text-3xl font-semibold text-neutral-900 tabular-nums">
{overview?.resolution ? overview.resolution.resolvedLast7d : <Skeleton className="h-8 w-12" />}
</CardTitle>
<CardAction className="px-0">
<Badge
variant="outline"
className={cn(
"gap-1 rounded-full border-border/60 px-3 py-1 text-xs font-semibold",
resolutionInfo?.delta === null
? "text-neutral-500"
: resolutionInfo?.delta !== null && resolutionInfo.delta < 0
? "text-amber-600"
: "text-emerald-600"
)}
>
{resolutionInfo?.delta !== null && resolutionInfo.delta < 0 ? (
<IconTrendingDown className="size-3.5" />
) : (
<IconTrendingUp className="size-3.5" />
)}
{resolutionInfo?.label}
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1 px-0 text-sm text-neutral-500">
<span>{resolutionFooter}</span>
<span className="text-xs text-neutral-400">Comparação com os 7 dias anteriores.</span>
</CardFooter>
</Card>
</div>
</div>
</div>
)
}
export function DashboardQueueInsights() {
const { session, convexUserId, isStaff } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const dashboardEnabled = Boolean(isStaff && convexUserId)
const overview = useQuery(
api.reports.dashboardOverview,
dashboardEnabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as DashboardOverview | undefined
const queueTrend = useQuery(
api.reports.queueLoadTrend,
dashboardEnabled ? { tenantId, viewerId: convexUserId as Id<"users">, range: "30d", limit: 3 } : "skip"
) as QueueTrendResponse | undefined
return (
<div className="grid gap-6 px-4 lg:grid-cols-2 lg:px-6">
<QueueSparklineRow
data={queueTrend?.queues}
isLoading={dashboardEnabled && queueTrend === undefined}
/>
<SlaAtRiskCard
data={overview?.awaitingAction}
isLoading={dashboardEnabled && overview === undefined}
/>
</div>
)
}
function QueueSparklineRow({
data,
isLoading,
}: {
data?: QueueTrendResponse["queues"]
isLoading: boolean
}) {
return (
<Card className="h-full rounded-3xl border border-border/60 bg-white/90 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between gap-4">
<div>
<CardTitle className="text-base font-semibold">Filas com maior volume</CardTitle>
<CardDescription>Comparativo diário de entradas x resolvidos</CardDescription>
</div>
<Link href="/queues/overview" className="text-sm font-semibold text-neutral-900 underline-offset-4 hover:underline">
Abrir painel de filas
</Link>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-2">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={`queue-sparkline-${index}`} className="h-56 w-full rounded-2xl" />
))}
</div>
) : !data || data.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/60 bg-muted/40 p-6 text-center text-sm text-muted-foreground">
Ainda não dados suficientes para exibir a tendência. Continue alimentando as filas ou reduza o período.
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-2">
{data.map((queue) => (
<QueueSparklineCard key={queue.id} queue={queue} />
))}
</div>
)}
</CardContent>
</Card>
)
}
function QueueSparklineCard({
queue,
}: {
queue: Required<QueueTrendResponse>["queues"][number]
}) {
const sanitizedId = queue.id.replace(/[^a-zA-Z0-9_-]/g, "")
const latest = queue.series.length > 0 ? queue.series[queue.series.length - 1] : null
const net = queue.openedTotal - queue.resolvedTotal
const netLabel =
net === 0 ? "Estável" : `${net > 0 ? "+" : ""}${net} no período`
const lastUpdated = latest
? formatDistanceToNow(new Date(latest.date), { addSuffix: true, locale: ptBR })
: null
return (
<Card className="rounded-2xl border border-border/60 bg-gradient-to-br from-white via-white to-primary/5 p-4 shadow-sm">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold text-neutral-900">{queue.name}</p>
<p className="text-xs text-neutral-500">
Última movimentação {lastUpdated ?? "—"}
</p>
</div>
<Badge
variant="outline"
className={cn(
"rounded-full px-3 py-1 text-xs font-medium",
net > 0 ? "text-amber-600" : net < 0 ? "text-emerald-600" : "text-neutral-500"
)}
>
{netLabel}
</Badge>
</div>
<div className="flex items-center gap-4 text-sm">
<div>
<p className="text-xs uppercase text-neutral-500">Entraram</p>
<p className="text-lg font-semibold text-neutral-900">{queue.openedTotal}</p>
</div>
<div>
<p className="text-xs uppercase text-neutral-500">Resolvidos</p>
<p className="text-lg font-semibold text-neutral-900">{queue.resolvedTotal}</p>
</div>
</div>
<ChartContainer config={queueSparkConfig} className="h-32 w-full">
<AreaChart data={queue.series}>
<defs>
<linearGradient id={`opened-${sanitizedId}`} x1="0" x2="0" y1="0" y2="1">
<stop offset="5%" stopColor="var(--color-opened)" stopOpacity={0.3} />
<stop offset="95%" stopColor="var(--color-opened)" stopOpacity={0.05} />
</linearGradient>
<linearGradient id={`resolved-${sanitizedId}`} x1="0" x2="0" y1="0" y2="1">
<stop offset="5%" stopColor="var(--color-resolved)" stopOpacity={0.3} />
<stop offset="95%" stopColor="var(--color-resolved)" stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} strokeDasharray="3 3" opacity={0.2} />
<XAxis dataKey="date" tickFormatter={(value) => formatDateDM(new Date(value))} tickLine={false} axisLine={false} tickMargin={8} minTickGap={32} />
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(value) => formatDateDMY(new Date(value as string))}
valueFormatter={(value) =>
typeof value === "number" ? value.toLocaleString("pt-BR") : value
}
/>
}
/>
<Area
type="monotone"
dataKey="opened"
stroke="var(--color-opened)"
fill={`url(#opened-${sanitizedId})`}
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="resolved"
stroke="var(--color-resolved)"
fill={`url(#resolved-${sanitizedId})`}
strokeWidth={2}
/>
</AreaChart>
</ChartContainer>
</div>
</Card>
)
}
function SlaAtRiskCard({
data,
isLoading,
}: {
data?: { total: number; atRisk: number }
isLoading: boolean
}) {
return (
<Card className="h-full rounded-3xl border border-border/60 bg-white/90 shadow-sm">
<CardHeader>
<CardTitle className="text-base font-semibold">SLA em risco</CardTitle>
<CardDescription>Tickets com solução prevista nas próximas horas</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-4">
<div className="rounded-2xl bg-gradient-to-br from-amber-50 to-amber-100/60 p-3 shadow-inner">
<IconClockHour4 className="size-8 text-amber-600" />
</div>
<div>
<p className="text-3xl font-semibold text-neutral-900">
{isLoading ? <Skeleton className="h-7 w-16" /> : data?.atRisk ?? 0}
</p>
<p className="text-sm text-neutral-500">
de {isLoading ? "—" : data?.total ?? 0} tickets ativos
</p>
</div>
</div>
<div className="rounded-2xl border border-dashed border-border/60 bg-amber-50/70 p-3 text-sm text-amber-900">
Foque nesses atendimentos para preservar o SLA. Utilize o filtro em risco na lista.
</div>
<ButtonLink href="/tickets?status=AWAITING_ATTENDANCE&priority=URGENT">
Priorizar agora
</ButtonLink>
</CardContent>
</Card>
)
}
function ButtonLink({ href, children }: { href: string; children: ReactNode }) {
return (
<Link
href={href}
className="inline-flex items-center justify-center rounded-2xl border border-neutral-900 bg-neutral-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-neutral-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/30"
>
{children}
</Link>
)
}

View file

@ -269,7 +269,7 @@ export function DashboardListView() {
<div className="flex w-full flex-col gap-3 rounded-2xl border border-slate-200 bg-white/90 p-4 lg:w-auto lg:min-w-[220px]">
{renderCreateButton()}
<Button variant="outline" className="gap-2" asChild>
<Link href="/views">
<Link href="/reports/sla">
<LayoutTemplate className="size-4" />
Ver exemplos
</Link>

View file

@ -0,0 +1,116 @@
"use client"
import { useId, useMemo } from "react"
import { useRouter } from "next/navigation"
import { MonitorSmartphone, Building, UserPlus, ChevronRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { NewTicketDialogDeferred } from "@/components/tickets/new-ticket-dialog.client"
import { useAuth } from "@/lib/auth-client"
import { cn } from "@/lib/utils"
type QuickLink = {
id: string
label: string
description?: string
icon: React.ComponentType<{ className?: string }>
href: string
visible: boolean
}
export function GlobalQuickActions() {
const { convexUserId, isAdmin, isStaff, isLoading } = useAuth()
const router = useRouter()
const actionId = useId()
const links = useMemo<QuickLink[]>(() => {
const base: QuickLink[] = [
{
id: "device",
label: "Adicionar dispositivo",
description: "Registrar agente, serial ou ativo remoto",
icon: MonitorSmartphone,
href: "/admin/devices?quick=new-device",
visible: Boolean(isAdmin),
},
{
id: "company",
label: "Adicionar empresa",
description: "Cadastrar novo cliente",
icon: Building,
href: "/admin/companies",
visible: Boolean(isAdmin),
},
{
id: "user",
label: "Novo usuário",
description: "Gestores / colaboradores",
icon: UserPlus,
href: "/admin/users",
visible: Boolean(isAdmin),
},
]
return base.filter((link) => link.visible)
}, [isAdmin])
if (!isStaff && !isAdmin) {
return null
}
if (isLoading) {
return (
<div className="border-b border-border/60 bg-muted/30 px-4 py-3 lg:px-8">
<div className="flex gap-3 overflow-x-auto pb-1">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-12 w-48 rounded-2xl" />
))}
</div>
</div>
)
}
return (
<div className="border-b border-border/60 bg-background/80 px-4 py-3 backdrop-blur lg:px-8">
<div className="flex w-full flex-wrap gap-3 overflow-x-auto pb-1 md:overflow-visible">
<NewTicketDialogDeferred
triggerVariant="card"
triggerClassName="min-w-[200px] flex-1 md:min-w-[220px]"
/>
{links.map((link) => (
<button
key={link.id}
type="button"
onClick={() => router.push(link.href)}
className="group inline-flex h-auto min-w-[200px] flex-1 flex-col items-start justify-start rounded-2xl border border-border/60 bg-card px-4 py-3 text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 md:min-w-[220px]"
>
<div className="flex items-center justify-between gap-3">
<div className="rounded-xl bg-muted/70 p-2 text-muted-foreground">
<link.icon className="size-4" />
</div>
<ChevronRight className="size-4 text-muted-foreground transition group-hover:translate-x-1" />
</div>
<div className="mt-3 text-sm font-semibold text-foreground">{link.label}</div>
{link.description ? (
<p className="text-xs text-muted-foreground">{link.description}</p>
) : null}
</button>
))}
{convexUserId ? (
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/tickets?assignee=${String(convexUserId)}`)}
className="inline-flex h-auto min-w-[200px] flex-col items-start justify-start gap-1 rounded-2xl border-dashed px-4 py-3 text-left text-sm font-semibold text-muted-foreground hover:bg-muted/40 md:min-w-[220px]"
aria-describedby={actionId}
>
<span id={actionId} className="text-xs font-semibold uppercase tracking-wide text-muted-foreground/80">
Minha fila
</span>
<span className="text-base text-foreground">Ver tarefas atribuídas</span>
</Button>
) : null}
</div>
</div>
)
}

View file

@ -0,0 +1,678 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { toast } from "sonner"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { MultiValueInput } from "@/components/ui/multi-value-input"
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Skeleton } from "@/components/ui/skeleton"
const STATUS_OPTIONS = [
{ value: "investigating", label: "Investigando" },
{ value: "identified", label: "Identificado" },
{ value: "mitigated", label: "Mitigado" },
{ value: "resolved", label: "Resolvido" },
]
const SEVERITY_OPTIONS = [
{ value: "low", label: "Baixa" },
{ value: "medium", label: "Média" },
{ value: "high", label: "Alta" },
{ value: "critical", label: "Crítica" },
]
const STATUS_TONE: Record<string, { badge: string; border: string; label: string }> = {
investigating: { badge: "bg-amber-100 text-amber-900", border: "border-amber-200", label: "Investigando" },
identified: { badge: "bg-orange-100 text-orange-900", border: "border-orange-200", label: "Identificado" },
mitigated: { badge: "bg-blue-100 text-blue-900", border: "border-blue-200", label: "Mitigado" },
resolved: { badge: "bg-emerald-100 text-emerald-900", border: "border-emerald-200", label: "Resolvido" },
}
const SEVERITY_COLORS: Record<string, string> = {
low: "bg-slate-100 text-slate-800",
medium: "bg-amber-100 text-amber-900",
high: "bg-orange-100 text-orange-900",
critical: "bg-rose-100 text-rose-900",
}
type IncidentRecord = {
_id: string
tenantId: string
title: string
status: string
severity: string
impactSummary?: string | null
affectedQueues: string[]
ownerName?: string | null
ownerEmail?: string | null
startedAt: number
updatedAt: number
resolvedAt?: number | null
timeline: Array<{
id: string
authorId: Id<"users">
authorName?: string | null
message: string
type?: string | null
createdAt: number
}>
}
type IncidentWorkspaceProps = {
autoOpenDrawer?: boolean
}
export function IncidentWorkspace({ autoOpenDrawer = false }: IncidentWorkspaceProps) {
const { session, convexUserId, isStaff } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const canLoad = Boolean(convexUserId && isStaff)
const incidentsRemote = useQuery(
api.incidents.list,
canLoad ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as IncidentRecord[] | undefined
const createIncident = useMutation(api.incidents.createIncident)
const updateStatus = useMutation(api.incidents.updateIncidentStatus)
const bulkUpdateStatus = useMutation(api.incidents.bulkUpdateIncidentStatus)
const addUpdate = useMutation(api.incidents.addIncidentUpdate)
const incidents = useMemo(() => incidentsRemote ?? [], [incidentsRemote])
const [searchTerm, setSearchTerm] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [severityFilter, setSeverityFilter] = useState("all")
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [selectedIncidentId, setSelectedIncidentId] = useState<string | null>(null)
const [drawerOpen, setDrawerOpen] = useState(autoOpenDrawer)
const [bulkStatus, setBulkStatus] = useState<string | null>(null)
const [updateMessage, setUpdateMessage] = useState("")
const [updateStatusSelection, setUpdateStatusSelection] = useState<string>("")
const [isSubmittingUpdate, setIsSubmittingUpdate] = useState(false)
useEffect(() => {
if (!selectedIncidentId && incidents.length > 0) {
setSelectedIncidentId(incidents[0]._id)
}
}, [incidents, selectedIncidentId])
useEffect(() => {
if (autoOpenDrawer) {
setDrawerOpen(true)
}
}, [autoOpenDrawer])
const filteredIncidents = useMemo(() => {
return incidents.filter((incident) => {
if (statusFilter !== "all" && incident.status !== statusFilter) return false
if (severityFilter !== "all" && incident.severity !== severityFilter) return false
if (searchTerm.trim().length > 0) {
const term = searchTerm.trim().toLowerCase()
const haystack = `${incident.title} ${incident.impactSummary ?? ""}`.toLowerCase()
if (!haystack.includes(term)) return false
}
return true
})
}, [incidents, searchTerm, severityFilter, statusFilter])
const stats = useMemo(() => {
const totals: Record<string, number> = {}
STATUS_OPTIONS.forEach((option) => {
totals[option.value] = incidents.filter((incident) => incident.status === option.value).length
})
return totals
}, [incidents])
const selectedIncident = incidents.find((incident) => incident._id === selectedIncidentId) ?? null
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedIds(filteredIncidents.map((incident) => incident._id))
} else {
setSelectedIds([])
}
}
const handleBulkStatus = async () => {
if (!bulkStatus || selectedIds.length === 0 || !convexUserId) return
try {
await bulkUpdateStatus({
tenantId,
actorId: convexUserId as Id<"users">,
incidentIds: selectedIds as Id<"incidents">[],
status: bulkStatus,
})
toast.success("Status atualizado para os incidentes selecionados.")
setSelectedIds([])
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar os incidentes.")
}
}
const handleSubmitUpdate = async () => {
if (!selectedIncident || !convexUserId || updateMessage.trim().length < 3) return
setIsSubmittingUpdate(true)
try {
await addUpdate({
tenantId,
actorId: convexUserId as Id<"users">,
incidentId: selectedIncident._id as Id<"incidents">,
message: updateMessage,
status: updateStatusSelection || undefined,
})
setUpdateMessage("")
setUpdateStatusSelection("")
toast.success("Atualização registrada.")
} catch (error) {
console.error(error)
toast.error("Não foi possível registrar a atualização.")
} finally {
setIsSubmittingUpdate(false)
}
}
if (!isStaff) {
return (
<Card className="rounded-3xl border border-border/60 bg-white/90 shadow-sm">
<CardContent className="p-6 text-sm text-neutral-600">
Apenas membros da equipe interna podem acessar o módulo de incidentes.
</CardContent>
</Card>
)
}
const handleCreateIncident = async (payload: NewIncidentPayload) => {
if (!convexUserId) return
try {
await createIncident({
tenantId,
actorId: convexUserId as Id<"users">,
title: payload.title,
severity: payload.severity,
impactSummary: payload.impactSummary,
affectedQueues: payload.affectedQueues,
initialUpdate: payload.initialUpdate,
} as never)
toast.success("Incidente criado com sucesso.")
setDrawerOpen(false)
} catch (error) {
console.error(error)
toast.error("Não foi possível criar o incidente.")
}
}
const handleStatusShortcut = async (status: string) => {
if (!selectedIncident || !convexUserId) return
try {
await updateStatus({
tenantId,
actorId: convexUserId as Id<"users">,
incidentId: selectedIncident._id as Id<"incidents">,
status,
})
toast.success(`Status atualizado para ${STATUS_TONE[status]?.label ?? status}.`)
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar o status do incidente.")
}
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="ghost"
onClick={() => incidentsRemote && toast.info("Dados atualizados automaticamente.")}
className="text-sm"
>
Atualizar
</Button>
<Button onClick={() => setDrawerOpen(true)}>Novo incidente</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{STATUS_OPTIONS.map((option) => {
const tone = STATUS_TONE[option.value]
const total = stats[option.value] ?? 0
return (
<Card
key={option.value}
className={cn(
"rounded-3xl border bg-white/90 shadow-sm",
tone?.border ?? "border-border/60"
)}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-4 py-3">
<CardTitle className="text-sm font-medium text-neutral-600">{option.label}</CardTitle>
<Badge variant="secondary" className={tone?.badge ?? "bg-slate-100 text-slate-800"}>
{total}
</Badge>
</CardHeader>
<CardContent className="px-4 pb-4">
<p className="text-2xl font-semibold text-neutral-900">{total}</p>
<p className="text-xs text-neutral-500">{total === 1 ? "Incidente" : "Incidentes"}</p>
</CardContent>
</Card>
)
})}
</div>
<Card className="rounded-3xl border border-border/60 bg-white/90 shadow-sm">
<CardContent className="space-y-4 p-4">
<div className="flex flex-wrap items-center gap-3">
<Input
placeholder="Buscar por título ou impacto"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
className="min-w-[240px] flex-1"
/>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os status</SelectItem>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={severityFilter} onValueChange={setSeverityFilter}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Severidade" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as severidades</SelectItem>
{SEVERITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="overflow-x-auto rounded-xl border border-slate-200">
<table className="min-w-full divide-y divide-slate-200 text-left text-sm">
<thead className="bg-slate-50 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<tr>
<th className="px-3 py-2">
<Checkbox
checked={selectedIds.length > 0 && selectedIds.length === filteredIncidents.length}
onCheckedChange={(checked) => handleSelectAll(Boolean(checked))}
/>
</th>
<th className="px-3 py-2">Título</th>
<th className="px-3 py-2">Severidade</th>
<th className="px-3 py-2">Status</th>
<th className="px-3 py-2">Owner</th>
<th className="px-3 py-2">Atualizado</th>
</tr>
</thead>
<tbody>
{filteredIncidents.length === 0 ? (
<tr>
<td colSpan={6} className="px-3 py-6 text-center text-sm text-neutral-500">
Nenhum incidente encontrado.
</td>
</tr>
) : (
filteredIncidents.map((incident) => {
const severityClass = SEVERITY_COLORS[incident.severity] ?? SEVERITY_COLORS.medium
const tone = STATUS_TONE[incident.status]
const isSelected = selectedIds.includes(incident._id)
return (
<tr
key={incident._id}
className={`cursor-pointer border-b border-slate-100 transition hover:bg-slate-50 ${selectedIncidentId === incident._id ? "bg-slate-50" : "bg-white"}`}
onClick={() => setSelectedIncidentId(incident._id)}
>
<td className="px-3 py-2">
<Checkbox
checked={isSelected}
onCheckedChange={(checked) => {
if (checked) {
setSelectedIds((prev) => (prev.includes(incident._id) ? prev : [...prev, incident._id]))
} else {
setSelectedIds((prev) => prev.filter((item) => item !== incident._id))
}
}}
onClick={(event) => event.stopPropagation()}
/>
</td>
<td className="px-3 py-2 font-semibold text-neutral-900">
{incident.title}
</td>
<td className="px-3 py-2">
<Badge variant="secondary" className={severityClass}>
{SEVERITY_OPTIONS.find((option) => option.value === incident.severity)?.label ?? incident.severity}
</Badge>
</td>
<td className="px-3 py-2">
<Badge variant="secondary" className={tone?.badge ?? "bg-slate-100 text-slate-800"}>
{tone?.label ?? incident.status}
</Badge>
</td>
<td className="px-3 py-2 text-neutral-600">{incident.ownerName ?? incident.ownerEmail ?? "—"}</td>
<td className="px-3 py-2 text-xs text-neutral-500">
{formatDistanceToNow(incident.updatedAt, { addSuffix: true, locale: ptBR })}
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-neutral-500">
{filteredIncidents.length} incidente{filteredIncidents.length === 1 ? "" : "s"} listados
</p>
<div className="flex items-center gap-2">
<Select value={bulkStatus ?? undefined} onValueChange={(value) => setBulkStatus(value)}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Status em massa" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" onClick={handleBulkStatus} disabled={!bulkStatus || selectedIds.length === 0}>
Atualizar status
</Button>
{bulkStatus ? (
<Button variant="ghost" size="sm" onClick={() => setBulkStatus(null)}>
Limpar
</Button>
) : null}
</div>
</div>
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-[1.5fr_1fr]">
<Card className="rounded-3xl border border-border/60 bg-white/90 shadow-sm">
<CardHeader>
<CardTitle className="text-base font-semibold text-neutral-900">Atualizações do incidente</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!selectedIncident ? (
<Skeleton className="h-48 w-full rounded-2xl" />
) : (
<>
<ScrollArea className="h-64 rounded-xl border border-slate-100">
<div className="space-y-4 p-4">
{selectedIncident.timeline.length === 0 ? (
<p className="text-sm text-neutral-500">Nenhuma atualização registrada.</p>
) : (
selectedIncident.timeline
.slice()
.sort((a, b) => a.createdAt - b.createdAt)
.map((entry) => (
<div key={entry.id} className="rounded-xl border border-slate-100 bg-slate-50 p-3 text-sm">
<p className="font-semibold text-neutral-900">{entry.authorName ?? "Equipe"}</p>
<p className="text-xs text-neutral-500">
{formatDistanceToNow(entry.createdAt, { addSuffix: true, locale: ptBR })}
</p>
<p className="mt-1 text-neutral-700">{entry.message}</p>
</div>
))
)}
</div>
</ScrollArea>
<div className="space-y-2">
<Textarea
value={updateMessage}
onChange={(event) => setUpdateMessage(event.target.value)}
placeholder="Compartilhe o progresso ou instruções para a equipe..."
rows={3}
/>
<div className="flex flex-wrap items-center gap-2">
<Select value={updateStatusSelection || undefined} onValueChange={setUpdateStatusSelection}>
<SelectTrigger className="w-[220px]">
<SelectValue placeholder="Manter status" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{updateStatusSelection ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-xs"
onClick={() => setUpdateStatusSelection("")}
>
Limpar
</Button>
) : null}
<Button onClick={handleSubmitUpdate} disabled={isSubmittingUpdate || updateMessage.trim().length < 3}>
{isSubmittingUpdate ? "Registrando..." : "Registrar atualização"}
</Button>
</div>
</div>
</>
)}
</CardContent>
</Card>
<Card className="rounded-3xl border border-border/60 bg-white/90 shadow-sm">
<CardHeader>
<CardTitle className="text-base font-semibold text-neutral-900">Resumo</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm text-neutral-600">
{!selectedIncident ? (
<Skeleton className="h-48 w-full rounded-2xl" />
) : (
<>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Título</p>
<p className="font-semibold text-neutral-900">{selectedIncident.title}</p>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Impacto</p>
<p>{selectedIncident.impactSummary ?? "Sem descrição."}</p>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Filas afetadas</p>
<div className="flex flex-wrap gap-2">
{selectedIncident.affectedQueues.length === 0 ? (
<span className="text-neutral-500">Não informado</span>
) : (
selectedIncident.affectedQueues.map((queue) => (
<Badge key={queue} variant="secondary" className="bg-slate-100 text-slate-800">
{queue}
</Badge>
))
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
{STATUS_OPTIONS.map((option) => (
<Button
key={option.value}
variant={selectedIncident.status === option.value ? "default" : "outline"}
size="sm"
onClick={() => handleStatusShortcut(option.value)}
>
{option.label}
</Button>
))}
</div>
</>
)}
</CardContent>
</Card>
</div>
<NewIncidentDrawer open={drawerOpen} onOpenChange={setDrawerOpen} onSubmit={handleCreateIncident} />
</div>
)
}
type NewIncidentPayload = {
title: string
severity: string
impactSummary: string
affectedQueues: string[]
initialUpdate: string
}
function NewIncidentDrawer({
open,
onOpenChange,
onSubmit,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (payload: NewIncidentPayload) => Promise<void>
}) {
const [title, setTitle] = useState("")
const [severity, setSeverity] = useState("high")
const [impactSummary, setImpactSummary] = useState("")
const [affectedQueues, setAffectedQueues] = useState<string[]>([])
const [initialUpdate, setInitialUpdate] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
useEffect(() => {
if (!open) {
setTitle("")
setSeverity("high")
setImpactSummary("")
setAffectedQueues([])
setInitialUpdate("")
setIsSubmitting(false)
}
}, [open])
const handleSubmit = async () => {
if (title.trim().length < 3) {
toast.error("Informe um título válido.")
return
}
setIsSubmitting(true)
try {
await onSubmit({
title: title.trim(),
severity,
impactSummary: impactSummary.trim(),
affectedQueues,
initialUpdate: initialUpdate.trim(),
})
} catch (error) {
console.error(error)
toast.error("Não foi possível criar o incidente.")
} finally {
setIsSubmitting(false)
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-6 overflow-y-auto p-0 sm:max-w-xl">
<div className="space-y-6 px-6 py-6">
<SheetHeader>
<SheetTitle>Novo incidente</SheetTitle>
<SheetDescription>Detalhe o incidente, defina prioridade e informe o impacto inicial.</SheetDescription>
</SheetHeader>
<div className="space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium text-neutral-800">Título</p>
<Input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="Ex.: Instabilidade no e-mail corporativo" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<p className="text-sm font-medium text-neutral-800">Severidade</p>
<Select value={severity} onValueChange={setSeverity}>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
{SEVERITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-neutral-800">Impacto resumido</p>
<Textarea
value={impactSummary}
onChange={(event) => setImpactSummary(event.target.value)}
rows={3}
placeholder="Descreva o efeito para clientes e equipes."
/>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-neutral-800">Filas afetadas</p>
<MultiValueInput
placeholder="Adicionar fila"
values={affectedQueues}
onChange={setAffectedQueues}
/>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-neutral-800">Atualização inicial</p>
<Textarea
value={initialUpdate}
onChange={(event) => setInitialUpdate(event.target.value)}
rows={4}
placeholder="Compartilhe contexto para a equipe."
/>
</div>
</div>
</div>
<SheetFooter className="border-t border-slate-200 px-6 py-4">
<div className="flex w-full items-center justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Cancelar
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? "Criando..." : "Registrar incidente"}
</Button>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
)
}

View file

@ -7,15 +7,21 @@ import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
import { Pie, PieChart, Bar, BarChart, CartesianGrid, XAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { ReportsFilterToolbar } from "@/components/reports/report-filter-toolbar"
import { ReportScheduleDrawer } from "@/components/reports/report-schedule-drawer"
const PRIORITY_LABELS: Record<string, string> = {
LOW: "Baixa",
@ -40,7 +46,8 @@ const queueBacklogChartConfig = {
export function BacklogReport() {
const [timeRange, setTimeRange] = useState("90d")
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
const { session, convexUserId, isStaff } = useAuth()
const [schedulerOpen, setSchedulerOpen] = useState(false)
const { session, convexUserId, isStaff, isAdmin } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const enabled = Boolean(isStaff && convexUserId)
const data = useQuery(
@ -85,6 +92,27 @@ export function BacklogReport() {
return (
<div className="space-y-8">
{isAdmin ? (
<ReportScheduleDrawer
open={schedulerOpen}
onOpenChange={setSchedulerOpen}
defaultReports={["backlog"]}
defaultRange={timeRange}
defaultCompanyId={companyId === "all" ? null : companyId}
companyOptions={companyOptions}
/>
) : null}
<ReportsFilterToolbar
companyId={companyId}
onCompanyChange={(value) => setCompanyId(value)}
companyOptions={companyOptions}
timeRange={timeRange as "90d" | "30d" | "7d"}
onTimeRangeChange={(value) => setTimeRange(value)}
exportHref={`/api/reports/backlog.xlsx?range=${timeRange}${
companyId !== "all" ? `&companyId=${companyId}` : ""
}`}
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
/>
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-slate-200">
<CardHeader>
@ -131,35 +159,6 @@ export function BacklogReport() {
<CardDescription className="text-neutral-600">
Acompanhe a evolução dos tickets pelas fases do fluxo de atendimento.
</CardDescription>
<CardAction>
<div className="flex flex-wrap items-center justify-end gap-2 md:gap-3">
<SearchableCombobox
value={companyId}
onValueChange={(next) => setCompanyId(next ?? "all")}
options={companyOptions}
placeholder="Todas as empresas"
className="w-full min-w-56 md:w-56"
/>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={setTimeRange}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:!px-4 md:flex"
>
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
</ToggleGroup>
<Button asChild size="sm" variant="outline">
<a href={`/api/reports/backlog.xlsx?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`} download>
Exportar XLSX
</a>
</Button>
</div>
</CardAction>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">

View file

@ -10,13 +10,15 @@ import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { ReportsFilterToolbar } from "@/components/reports/report-filter-toolbar"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { ReportScheduleDrawer } from "@/components/reports/report-schedule-drawer"
type CategoryInsightsResponse = {
rangeDays: number
@ -49,10 +51,11 @@ const chartConfig = {
} as const
export function CategoryReport() {
const { session, convexUserId, isStaff } = useAuth()
const { session, convexUserId, isStaff, isAdmin } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const [timeRange, setTimeRange] = useState("90d")
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
const [schedulerOpen, setSchedulerOpen] = useState(false)
const enabled = Boolean(isStaff && convexUserId)
const companyFilter = companyId !== "all" ? (companyId as Id<"companies">) : undefined
@ -164,34 +167,31 @@ export function CategoryReport() {
</div>
)}
{isAdmin ? (
<ReportScheduleDrawer
open={schedulerOpen}
onOpenChange={setSchedulerOpen}
defaultRange={timeRange}
defaultCompanyId={companyId === "all" ? null : companyId}
companyOptions={companyOptions}
/>
) : null}
<ReportsFilterToolbar
companyId={companyId}
onCompanyChange={(value) => setCompanyId(value)}
companyOptions={companyOptions}
timeRange={timeRange as "90d" | "30d" | "7d"}
onTimeRangeChange={(value) => setTimeRange(value)}
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
/>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Categorias mais atendidas</CardTitle>
<CardDescription className="text-neutral-600">
Compare o volume de solicitações por categoria e identifique quais agentes concentram o atendimento de cada tema.
</CardDescription>
<CardAction>
<div className="flex flex-wrap items-center justify-end gap-2">
<SearchableCombobox
value={companyId}
onValueChange={(next) => setCompanyId(next ?? "all")}
options={companyOptions}
placeholder="Todas as empresas"
className="w-full min-w-56 md:w-64"
/>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={(value) => value && setTimeRange(value)}
variant="outline"
className="hidden md:flex"
>
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
</ToggleGroup>
</div>
</CardAction>
</CardHeader>
<CardContent className="space-y-6">
{!data ? (

View file

@ -6,16 +6,16 @@ import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { useState } from "react"
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { Button } from "@/components/ui/button"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
import { ReportsFilterToolbar } from "@/components/reports/report-filter-toolbar"
import { ReportScheduleDrawer } from "@/components/reports/report-schedule-drawer"
function formatScore(value: number | null) {
if (value === null) return "—"
@ -25,7 +25,8 @@ function formatScore(value: number | null) {
export function CsatReport() {
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
const [timeRange, setTimeRange] = useState<string>("90d")
const { session, convexUserId, isStaff } = useAuth()
const [schedulerOpen, setSchedulerOpen] = useState(false)
const { session, convexUserId, isStaff, isAdmin } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const enabled = Boolean(isStaff && convexUserId)
const data = useQuery(
@ -78,41 +79,27 @@ export function CsatReport() {
return (
<div className="space-y-8">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h2 className="text-xl font-semibold text-neutral-900">CSAT Satisfação dos chamados</h2>
<p className="text-sm text-neutral-600">
Avalie a experiência dos usuários e acompanhe o desempenho da equipe de atendimento.
</p>
</div>
<div className="flex flex-wrap items-center gap-2 md:justify-end">
<SearchableCombobox
value={selectedCompany}
onValueChange={handleCompanyChange}
options={comboboxOptions}
className="w-56"
placeholder="Filtrar por empresa"
/>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={(value) => {
if (value) setTimeRange(value)
}}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:!px-4 md:flex"
>
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
</ToggleGroup>
<Button asChild size="sm" variant="outline">
<a href={`/api/reports/csat.xlsx?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`} download>
Exportar XLSX
</a>
</Button>
</div>
</div>
{isAdmin ? (
<ReportScheduleDrawer
open={schedulerOpen}
onOpenChange={setSchedulerOpen}
defaultReports={["csat"]}
defaultRange={timeRange}
defaultCompanyId={companyId === "all" ? null : companyId}
companyOptions={comboboxOptions}
/>
) : null}
<ReportsFilterToolbar
companyId={selectedCompany}
onCompanyChange={(value) => handleCompanyChange(value)}
companyOptions={comboboxOptions}
timeRange={timeRange as "90d" | "30d" | "7d"}
onTimeRangeChange={(value) => setTimeRange(value)}
exportHref={`/api/reports/csat.xlsx?range=${timeRange}${
companyId !== "all" ? `&companyId=${companyId}` : ""
}`}
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
/>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card className="border-slate-200">

View file

@ -7,17 +7,29 @@ import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
import { Progress } from "@/components/ui/progress"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import type { ChartConfig } from "@/components/ui/chart"
import { formatHoursCompact } from "@/lib/utils"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { ReportsFilterToolbar } from "@/components/reports/report-filter-toolbar"
import { ReportScheduleDrawer } from "@/components/reports/report-schedule-drawer"
type HoursItem = {
companyId: string
@ -43,7 +55,9 @@ const topClientsChartConfig = {
export function HoursReport() {
const [timeRange, setTimeRange] = useState("90d")
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
const { session, convexUserId, isStaff } = useAuth()
const [billingFilter, setBillingFilter] = useState<"all" | "avulso" | "contratado">("all")
const [schedulerOpen, setSchedulerOpen] = useState(false)
const { session, convexUserId, isStaff, isAdmin } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const enabled = Boolean(isStaff && convexUserId)
@ -71,12 +85,17 @@ export function HoursReport() {
]
}, [companies])
const filtered = useMemo(() => {
const items = data?.items ?? []
let items = data?.items ?? []
if (companyId !== "all") {
return items.filter((it) => String(it.companyId) === companyId)
items = items.filter((it) => String(it.companyId) === companyId)
}
if (billingFilter === "avulso") {
items = items.filter((it) => it.isAvulso)
} else if (billingFilter === "contratado") {
items = items.filter((it) => !it.isAvulso)
}
return items
}, [data?.items, companyId])
}, [data?.items, companyId, billingFilter])
const totals = useMemo(() => {
return filtered.reduce(
@ -128,6 +147,30 @@ export function HoursReport() {
return (
<div className="space-y-6">
{isAdmin ? (
<ReportScheduleDrawer
open={schedulerOpen}
onOpenChange={setSchedulerOpen}
defaultReports={["hours"]}
defaultRange={timeRange}
defaultCompanyId={companyId === "all" ? null : companyId}
companyOptions={companyOptions}
/>
) : null}
<ReportsFilterToolbar
companyId={companyId}
onCompanyChange={(value) => setCompanyId(value)}
companyOptions={companyOptions}
timeRange={timeRange as "90d" | "30d" | "7d"}
onTimeRangeChange={(value) => setTimeRange(value)}
showBillingFilter
billingFilter={billingFilter}
onBillingFilterChange={(value) => setBillingFilter(value)}
exportHref={`/api/reports/hours-by-client.xlsx?range=${timeRange}${
companyId !== "all" ? `&companyId=${companyId}` : ""
}`}
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
/>
<Card className="border-slate-200">
<CardHeader>
<CardTitle>Top clientes por horas</CardTitle>
@ -176,45 +219,10 @@ export function HoursReport() {
)}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle>Horas</CardTitle>
<CardDescription>Visualize o esforço interno e externo por empresa e acompanhe o consumo contratado.</CardDescription>
<CardAction>
<div className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<SearchableCombobox
value={companyId}
onValueChange={(next) => setCompanyId(next ?? "all")}
options={companyOptions}
placeholder="Todas as empresas"
className="w-full min-w-56 lg:w-72"
/>
<div className="flex flex-wrap items-center gap-2">
{["90d", "30d", "7d"].map((range) => (
<Button
key={range}
type="button"
size="sm"
variant={timeRange === range ? "default" : "outline"}
onClick={() => setTimeRange(range)}
>
{range === "90d" ? "90 dias" : range === "30d" ? "30 dias" : "7 dias"}
</Button>
))}
<Button asChild size="sm" variant="outline" className="gap-2">
<a
href={`/api/reports/hours-by-client.xlsx?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`}
download
>
Exportar XLSX
</a>
</Button>
</div>
</div>
</div>
</CardAction>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">

View file

@ -0,0 +1,111 @@
"use client"
import type { ReactNode } from "react"
import { Button } from "@/components/ui/button"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
type BillingFilter = "all" | "avulso" | "contratado"
type TimeRange = "90d" | "30d" | "7d"
type ReportsFilterToolbarProps = {
companyId: string
onCompanyChange: (value: string) => void
companyOptions: SearchableComboboxOption[]
timeRange: TimeRange
onTimeRangeChange: (value: TimeRange) => void
showBillingFilter?: boolean
billingFilter?: BillingFilter
onBillingFilterChange?: (value: BillingFilter) => void
extraFilters?: ReactNode
exportHref?: string
exportLabel?: string
onExportClick?: () => void
isExporting?: boolean
onOpenScheduler?: () => void
}
export function ReportsFilterToolbar({
companyId,
onCompanyChange,
companyOptions,
timeRange,
onTimeRangeChange,
showBillingFilter = false,
billingFilter = "all",
onBillingFilterChange,
extraFilters,
exportHref,
exportLabel = "Exportar XLSX",
onExportClick,
isExporting = false,
onOpenScheduler,
}: ReportsFilterToolbarProps) {
return (
<div className="flex flex-col gap-3 rounded-3xl border border-border/60 bg-white/90 p-4 shadow-sm">
<div className="flex flex-wrap items-center gap-3">
<SearchableCombobox
value={companyId}
onValueChange={(next) => onCompanyChange(next ?? "all")}
options={companyOptions}
placeholder="Todas as empresas"
className="w-full min-w-56 md:w-64"
/>
{showBillingFilter ? (
<ToggleGroup
type="single"
value={billingFilter}
onValueChange={(next) => next && onBillingFilterChange?.(next as BillingFilter)}
className="inline-flex rounded-full border border-border/60 bg-muted/40 p-1"
>
<ToggleGroupItem value="all" className="rounded-full px-3 text-xs">
Todos
</ToggleGroupItem>
<ToggleGroupItem value="avulso" className="rounded-full px-3 text-xs">
Somente avulsos
</ToggleGroupItem>
<ToggleGroupItem value="contratado" className="rounded-full px-3 text-xs">
Somente contratados
</ToggleGroupItem>
</ToggleGroup>
) : null}
<ToggleGroup
type="single"
value={timeRange}
onValueChange={(value) => value && onTimeRangeChange(value as TimeRange)}
variant="outline"
size="lg"
className="flex rounded-2xl border border-border/60"
>
<ToggleGroupItem value="90d" className="min-w-[80px] justify-center px-4">
90 dias
</ToggleGroupItem>
<ToggleGroupItem value="30d" className="min-w-[80px] justify-center px-4">
30 dias
</ToggleGroupItem>
<ToggleGroupItem value="7d" className="min-w-[80px] justify-center px-4">
7 dias
</ToggleGroupItem>
</ToggleGroup>
<div className="ml-auto flex items-center gap-2">
{onOpenScheduler ? (
<Button variant="outline" size="sm" onClick={onOpenScheduler}>
Agendar exportação
</Button>
) : null}
{exportHref ? (
<Button asChild size="sm" variant="outline" disabled={isExporting}>
<a href={exportHref}>{exportLabel}</a>
</Button>
) : onExportClick ? (
<Button size="sm" variant="outline" onClick={onExportClick} disabled={isExporting}>
{isExporting ? "Exportando..." : exportLabel}
</Button>
) : null}
</div>
</div>
{extraFilters}
</div>
)
}

View file

@ -0,0 +1,553 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { CalendarClock, Play, Trash2 } from "lucide-react"
import { REPORT_EXPORT_DEFINITIONS, type ReportExportKey } from "@/lib/report-definitions"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { MultiValueInput } from "@/components/ui/multi-value-input"
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
type SerializableRun = {
id: string
status: "PENDING" | "RUNNING" | "COMPLETED" | "FAILED"
startedAt: string
completedAt: string | null
error?: string | null
artifacts: Array<{
index: number
key: string
fileName: string | null
mimeType: string | null
}>
}
type SerializableSchedule = {
id: string
name: string
tenantId: string
reportKeys: ReportExportKey[]
range: string
companyId: string | null
companyName: string | null
format: string
frequency: "daily" | "weekly" | "monthly"
dayOfWeek: number | null
dayOfMonth: number | null
hour: number
minute: number
timezone: string
recipients: string[]
status: "ACTIVE" | "PAUSED"
lastRunAt: string | null
nextRunAt: string | null
runs: SerializableRun[]
}
const RUN_LABELS: Record<SerializableRun["status"], string> = {
PENDING: "Pendente",
RUNNING: "Em andamento",
COMPLETED: "Concluído",
FAILED: "Falhou",
}
const RUN_VARIANTS: Record<SerializableRun["status"], "default" | "outline" | "destructive"> = {
PENDING: "outline",
RUNNING: "outline",
COMPLETED: "default",
FAILED: "destructive",
}
function runStatusLabel(status: SerializableRun["status"]) {
return RUN_LABELS[status] ?? status
}
function runStatusVariant(status: SerializableRun["status"]) {
return RUN_VARIANTS[status] ?? "outline"
}
type ReportScheduleDrawerProps = {
open: boolean
onOpenChange: (open: boolean) => void
defaultReports?: ReportExportKey[]
defaultRange?: string
defaultCompanyId?: string | null
companyOptions: SearchableComboboxOption[]
}
export function ReportScheduleDrawer({
open,
onOpenChange,
defaultReports = [],
defaultRange = "30d",
defaultCompanyId = null,
companyOptions,
}: ReportScheduleDrawerProps) {
const [name, setName] = useState("")
const [selectedReports, setSelectedReports] = useState<ReportExportKey[]>(defaultReports)
const [range, setRange] = useState(defaultRange)
const [companyId, setCompanyId] = useState<string | null>(defaultCompanyId)
const [companyName, setCompanyName] = useState("")
const [frequency, setFrequency] = useState<"daily" | "weekly" | "monthly">("weekly")
const [dayOfWeek, setDayOfWeek] = useState<number | null>(1)
const [dayOfMonth, setDayOfMonth] = useState<number | null>(null)
const [time, setTime] = useState("08:00")
const [recipients, setRecipients] = useState<string[]>([])
const [isSaving, setIsSaving] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [schedules, setSchedules] = useState<SerializableSchedule[]>([])
const loadSchedules = useCallback(async () => {
setIsLoading(true)
try {
const response = await fetch("/api/reports/schedules", { cache: "no-store" })
if (!response.ok) throw new Error("Falha ao carregar agendamentos")
const data = (await response.json()) as { items: SerializableSchedule[] }
setSchedules(data.items ?? [])
} catch (error) {
console.error(error)
toast.error("Não foi possível carregar os agendamentos.")
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
if (open) {
void loadSchedules()
setSelectedReports(defaultReports.length ? defaultReports : [])
setCompanyId(defaultCompanyId)
}
}, [open, loadSchedules, defaultReports, defaultCompanyId])
useEffect(() => {
if (!companyId) {
setCompanyName("")
}
}, [companyId])
const resetForm = useCallback(() => {
setName("")
setSelectedReports(defaultReports.length ? defaultReports : [])
setRange(defaultRange)
setCompanyId(defaultCompanyId)
setCompanyName("")
setFrequency("weekly")
setDayOfWeek(1)
setDayOfMonth(null)
setTime("08:00")
setRecipients([])
}, [defaultReports, defaultRange, defaultCompanyId])
const canSubmit = name.trim().length > 2 && selectedReports.length > 0 && recipients.length > 0
const handleCreate = useCallback(async () => {
if (!canSubmit) return
setIsSaving(true)
try {
const response = await fetch("/api/reports/schedules", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
reportKeys: selectedReports,
range,
companyId,
companyName: companyName.trim() || null,
frequency,
dayOfWeek,
dayOfMonth,
time,
recipients,
}),
})
if (!response.ok) {
const payload = await response.json().catch(() => null)
throw new Error(payload?.error ?? "Falha ao criar agendamento")
}
await loadSchedules()
resetForm()
toast.success("Agendamento criado com sucesso.")
} catch (error) {
console.error(error)
toast.error(error instanceof Error ? error.message : "Erro ao criar agendamento")
} finally {
setIsSaving(false)
}
}, [
canSubmit,
name,
selectedReports,
range,
companyId,
companyName,
frequency,
dayOfWeek,
dayOfMonth,
time,
recipients,
loadSchedules,
resetForm,
])
const toggleReport = (key: ReportExportKey) => {
setSelectedReports((current) =>
current.includes(key) ? current.filter((item) => item !== key) : [...current, key]
)
}
const handleDelete = async (id: string) => {
if (!confirm("Deseja excluir este agendamento?")) return
try {
const response = await fetch(`/api/reports/schedules/${id}`, { method: "DELETE" })
if (!response.ok) throw new Error()
setSchedules((items) => items.filter((item) => item.id !== id))
toast.success("Agendamento removido.")
} catch {
toast.error("Não foi possível remover o agendamento.")
}
}
const handleToggleStatus = async (schedule: SerializableSchedule) => {
const nextStatus = schedule.status === "ACTIVE" ? "PAUSED" : "ACTIVE"
try {
const response = await fetch(`/api/reports/schedules/${schedule.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: nextStatus }),
})
if (!response.ok) throw new Error()
await loadSchedules()
toast.success(`Agendamento ${nextStatus === "ACTIVE" ? "reativado" : "pausado"}.`)
} catch {
toast.error("Não foi possível atualizar o agendamento.")
}
}
const handleRunNow = async (id: string) => {
toast.loading("Gerando relatórios...", { id: `run-${id}` })
try {
const response = await fetch("/api/reports/schedules/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scheduleId: id }),
})
if (!response.ok) throw new Error()
toast.success("Execução iniciada. Você receberá um e-mail assim que finalizar.", {
id: `run-${id}`,
})
await loadSchedules()
} catch {
toast.error("Não foi possível iniciar a execução.", { id: `run-${id}` })
}
}
const nextRunLabel = useCallback((schedule: SerializableSchedule) => {
if (!schedule.nextRunAt) return "Sem previsão"
return formatDistanceToNow(new Date(schedule.nextRunAt), { addSuffix: true, locale: ptBR })
}, [])
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-6 overflow-y-auto p-0 sm:max-w-3xl">
<div className="space-y-6 px-6 py-6">
<SheetHeader>
<SheetTitle>Agendar exportação</SheetTitle>
<SheetDescription>
Configure a periodicidade dos relatórios e defina quem receberá os arquivos automaticamente.
</SheetDescription>
</SheetHeader>
<div className="space-y-4 rounded-2xl border border-border/60 bg-muted/30 p-4">
<div className="space-y-2">
<Label htmlFor="schedule-name">Nome</Label>
<Input
id="schedule-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Ex.: Horas semanais clientes A/B"
/>
</div>
<div className="space-y-2">
<Label>Relatórios incluídos</Label>
<div className="flex flex-wrap gap-2">
{Object.entries(REPORT_EXPORT_DEFINITIONS).map(([key, meta]) => {
const typedKey = key as ReportExportKey
const active = selectedReports.includes(typedKey)
return (
<button
type="button"
key={key}
onClick={() => toggleReport(typedKey)}
className={`rounded-2xl border px-3 py-2 text-left text-sm ${
active
? "border-primary bg-primary/10 text-foreground"
: "border-border/70 bg-white text-muted-foreground"
}`}
>
<div className="font-semibold">{meta.label}</div>
<div className="text-xs text-muted-foreground">{meta.description}</div>
</button>
)
})}
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>Período padrão</Label>
<Select value={range} onValueChange={setRange}>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7d">Últimos 7 dias</SelectItem>
<SelectItem value="30d">Últimos 30 dias</SelectItem>
<SelectItem value="90d">Últimos 90 dias</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Empresa (opcional)</Label>
<SearchableCombobox
value={companyId ?? "all"}
onValueChange={(next) => setCompanyId(next && next !== "all" ? next : null)}
options={[{ value: "all", label: "Todas as empresas" }, ...companyOptions]}
placeholder="Todas as empresas"
/>
{companyId ? (
<Input
value={companyName}
onChange={(event) => setCompanyName(event.target.value)}
placeholder="Nome amigável para o relatório"
/>
) : null}
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>Frequência</Label>
<Select
value={frequency}
onValueChange={(value) => setFrequency(value as "daily" | "weekly" | "monthly")}
>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">Diária</SelectItem>
<SelectItem value="weekly">Semanal</SelectItem>
<SelectItem value="monthly">Mensal</SelectItem>
</SelectContent>
</Select>
</div>
{frequency === "weekly" ? (
<div className="space-y-2">
<Label>Dia da semana</Label>
<Select
value={String(dayOfWeek ?? 1)}
onValueChange={(value) => setDayOfWeek(Number(value))}
>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
{["Domingo", "Segunda", "Terça", "Quarta", "Quinta", "Sexta", "Sábado"].map(
(label, index) => (
<SelectItem key={label} value={String(index)}>
{label}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
) : frequency === "monthly" ? (
<div className="space-y-2">
<Label>Dia do mês</Label>
<Input
type="number"
min={1}
max={28}
value={dayOfMonth ?? ""}
onChange={(event) => setDayOfMonth(Number(event.target.value) || 1)}
/>
</div>
) : null}
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="schedule-time">Horário (HH:MM)</Label>
<Input
id="schedule-time"
type="time"
value={time}
onChange={(event) => setTime(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Destinatários (emails)</Label>
<MultiValueInput
placeholder="exemplo@empresa.com"
values={recipients}
onChange={setRecipients}
/>
</div>
</div>
<Button disabled={!canSubmit || isSaving} onClick={handleCreate}>
{isSaving ? "Salvando..." : "Salvar agendamento"}
</Button>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">Agendamentos ativos</h3>
<Button variant="ghost" size="sm" onClick={() => loadSchedules()} disabled={isLoading}>
Atualizar
</Button>
</div>
{schedules.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/60 bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
Nenhum agendamento criado ainda.
</div>
) : (
<div className="space-y-3">
{schedules.map((schedule) => (
<div
key={schedule.id}
className="rounded-2xl border border-border/60 bg-white px-4 py-3 shadow-sm"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold text-neutral-900">{schedule.name}</p>
<p className="text-xs text-neutral-500">
Próxima execução {nextRunLabel(schedule)}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant={schedule.status === "ACTIVE" ? "default" : "outline"}>
{schedule.status === "ACTIVE" ? "Ativo" : "Pausado"}
</Badge>
<Button
variant="ghost"
size="icon"
onClick={() => handleRunNow(schedule.id)}
title="Executar agora"
>
<Play className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleToggleStatus(schedule)}
title={schedule.status === "ACTIVE" ? "Pausar" : "Retomar"}
>
<CalendarClock className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(schedule.id)}
title="Excluir"
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
{schedule.reportKeys.map((key) => (
<Badge key={key} variant="outline">
{REPORT_EXPORT_DEFINITIONS[key]?.label ?? key}
</Badge>
))}
<span> Período: {schedule.range}</span>
{schedule.companyName ? (
<span> Empresa: {schedule.companyName}</span>
) : null}
<span>
Próxima execução às {String(schedule.hour).padStart(2, "0")}:
{String(schedule.minute).padStart(2, "0")}
</span>
</div>
<div className="mt-3 space-y-2 border-t border-border/60 pt-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Execuções recentes
</p>
{schedule.runs?.length ? (
schedule.runs.slice(0, 3).map((run) => (
<div
key={run.id}
className="rounded-xl border border-border/50 bg-muted/30 px-3 py-2 text-xs text-neutral-600"
>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={runStatusVariant(run.status)}>{runStatusLabel(run.status)}</Badge>
<span className="text-[11px] text-neutral-500">
{formatDistanceToNow(
new Date(run.completedAt ?? run.startedAt),
{ addSuffix: true, locale: ptBR }
)}
</span>
{run.error ? (
<span className="text-[11px] text-destructive">Erro: {run.error}</span>
) : null}
</div>
{run.artifacts?.length ? (
<div className="mt-2 flex flex-wrap gap-2">
{run.artifacts.map((artifact) => (
<Button
key={`${run.id}-${artifact.index}`}
variant="ghost"
size="sm"
asChild
>
<a
href={`/api/reports/schedules/runs/${run.id}?artifact=${artifact.index}`}
target="_blank"
rel="noreferrer"
>
{artifact.fileName ?? `Arquivo ${artifact.index + 1}`}
</a>
</Button>
))}
</div>
) : null}
</div>
))
) : (
<p className="text-[11px] text-neutral-500">Nenhuma execução registrada ainda.</p>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
<SheetFooter className="border-t border-border/60 px-6 py-4">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Fechar
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}

View file

@ -7,17 +7,18 @@ import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { useState } from "react"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
import { Area, AreaChart, Bar, BarChart, CartesianGrid, XAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { cn, formatDateDM, formatDateDMY, formatHoursCompact } from "@/lib/utils"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { ReportsFilterToolbar } from "@/components/reports/report-filter-toolbar"
import { ReportScheduleDrawer } from "@/components/reports/report-schedule-drawer"
import { ChartAreaInteractive } from "@/components/chart-area-interactive"
const agentProductivityChartConfig = {
resolved: {
@ -25,6 +26,12 @@ const agentProductivityChartConfig = {
},
}
const queueVolumeChartConfig = {
open: {
label: "Tickets abertos",
},
}
const priorityLabelMap: Record<string, string> = {
LOW: "Baixa",
MEDIUM: "Média",
@ -54,8 +61,9 @@ function formatMinutes(value: number | null) {
export function SlaReport() {
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
const [timeRange, setTimeRange] = useState<string>("90d")
const { session, convexUserId, isStaff } = useAuth()
const [timeRange, setTimeRange] = useState<"90d" | "30d" | "7d">("90d")
const [schedulerOpen, setSchedulerOpen] = useState(false)
const { session, convexUserId, isStaff, isAdmin } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const enabled = Boolean(isStaff && convexUserId)
const data = useQuery(
@ -78,12 +86,6 @@ export function SlaReport() {
: "skip"
) as { rangeDays: number; series: Array<{ date: string; opened: number; resolved: number }> } | undefined
const channelsSeries = useQuery(
api.reports.ticketsByChannel,
enabled
? ({ tenantId, viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) })
: "skip"
) as { rangeDays: number; channels: string[]; points: Array<{ date: string; values: Record<string, number> }> } | undefined
const companies = useQuery(
api.companies.list,
enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
@ -122,6 +124,27 @@ export function SlaReport() {
return (
<div className="space-y-8">
{isAdmin ? (
<ReportScheduleDrawer
open={schedulerOpen}
onOpenChange={setSchedulerOpen}
defaultReports={["sla"]}
defaultRange={timeRange}
defaultCompanyId={companyId === "all" ? null : companyId}
companyOptions={companyOptions}
/>
) : null}
<ReportsFilterToolbar
companyId={companyId}
onCompanyChange={(value) => setCompanyId(value)}
companyOptions={companyOptions}
timeRange={timeRange}
onTimeRangeChange={(value) => setTimeRange(value)}
exportHref={`/api/reports/sla.xlsx?range=${timeRange}${
companyId !== "all" ? `&companyId=${companyId}` : ""
}`}
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
/>
<div className="grid gap-4 md:grid-cols-4">
<Card className="border-slate-200">
<CardHeader>
@ -167,43 +190,10 @@ export function SlaReport() {
<Card className="border-slate-200">
<CardHeader>
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-lg font-semibold text-neutral-900">Fila x Volume aberto</CardTitle>
<CardDescription className="text-neutral-600">
Distribuição dos {queueTotal} tickets abertos por fila de atendimento.
</CardDescription>
</div>
<CardAction>
<div className="flex flex-wrap items-center justify-end gap-2 md:gap-3">
<SearchableCombobox
value={companyId}
onValueChange={(next) => setCompanyId(next ?? "all")}
options={companyOptions}
placeholder="Todas as empresas"
className="w-full min-w-56 sm:w-56"
/>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={setTimeRange}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:!px-4 md:flex"
>
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
</ToggleGroup>
<Button asChild size="sm" variant="outline">
<a href={`/api/reports/sla.xlsx?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`} download>
Exportar XLSX
</a>
</Button>
</div>
</CardAction>
</div>
<CardTitle className="text-lg font-semibold text-neutral-900">Fila x Volume aberto</CardTitle>
<CardDescription className="text-neutral-600">
Distribuição dos {queueTotal} tickets abertos por fila de atendimento.
</CardDescription>
</CardHeader>
<CardContent>
{data.queueBreakdown.length === 0 ? (
@ -228,6 +218,47 @@ export function SlaReport() {
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-lg font-semibold text-neutral-900">Filas com maior volume</CardTitle>
<CardDescription className="text-neutral-600">Visualize graficamente as filas com mais tickets abertos.</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{data.queueBreakdown.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
Nenhuma fila com tickets ativos no momento.
</p>
) : (
<ChartContainer config={queueVolumeChartConfig} className="aspect-auto h-[260px] w-full">
<BarChart
data={data.queueBreakdown.map((queue: { id: string; name: string; open: number }) => ({
name: queue.name,
open: queue.open,
}))}
margin={{ top: 8, left: 20, right: 20, bottom: 56 }}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="name"
tickLine={false}
axisLine={false}
tickMargin={24}
interval={0}
angle={-30}
height={80}
/>
<Bar dataKey="open" fill="var(--chart-1)" radius={[4, 4, 0, 0]} />
<ChartTooltip content={<ChartTooltipContent className="w-[180px]" nameKey="open" />} />
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
@ -332,46 +363,14 @@ export function SlaReport() {
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-lg font-semibold text-neutral-900">Volume por canal</CardTitle>
<CardDescription className="text-neutral-600">Distribuição diária por canal (empilhado).</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{!channelsSeries || channelsSeries.points.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">Sem dados para o período.</p>
) : (
<ChartContainer config={{}} className="aspect-auto h-[260px] w-full">
<AreaChart data={channelsSeries.points.map((p) => ({ date: p.date, ...p.values }))}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={24}
tickFormatter={(v) => formatDateDM(new Date(v))}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="w-[220px]"
labelFormatter={(value) => formatDateDMY(new Date(value as string))}
/>
}
/>
{channelsSeries.channels.map((ch, idx) => (
<Area key={ch} dataKey={ch} type="natural" stackId="a" stroke={`var(--chart-${(idx % 5) + 1})`} fill={`var(--chart-${(idx % 5) + 1})`} />
))}
</AreaChart>
</ChartContainer>
)}
</CardContent>
</Card>
<ChartAreaInteractive
hideControls
range={timeRange}
companyId={companyId}
className="border-slate-200"
title="Volume por canal"
description="Distribuição diária por canal (empilhado)."
/>
<Card className="border-slate-200">
<CardHeader>

View file

@ -1,223 +0,0 @@
"use client"
import { useMemo } from "react"
import { useQuery } from "convex/react"
import { IconClockHour4, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import {
Card,
CardAction,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
function formatMinutes(value: number | null) {
if (value === null) return "—"
if (value < 60) return `${Math.round(value)} min`
const hours = Math.floor(value / 60)
const minutes = Math.round(value % 60)
if (minutes === 0) return `${hours}h`
return `${hours}h ${minutes}min`
}
export function SectionCards() {
const { session, convexUserId, isStaff } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const dashboardEnabled = Boolean(isStaff && convexUserId)
const dashboard = useQuery(
api.reports.dashboardOverview,
dashboardEnabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
)
const inProgressSummary = useMemo(() => {
if (dashboard?.inProgress) {
return dashboard.inProgress
}
if (dashboard?.newTickets) {
return {
current: dashboard.newTickets.last24h,
previousSnapshot: dashboard.newTickets.previous24h,
trendPercentage: dashboard.newTickets.trendPercentage,
}
}
return null
}, [dashboard])
const trendInfo = useMemo(() => {
if (!inProgressSummary) return { value: null, label: "Aguardando dados", icon: IconTrendingUp }
const trend = inProgressSummary.trendPercentage
if (trend === null) {
return { value: null, label: "Sem histórico", icon: IconTrendingUp }
}
const positive = trend >= 0
const icon = positive ? IconTrendingUp : IconTrendingDown
const label = `${positive ? "+" : ""}${trend.toFixed(1)}%`
return { value: trend, label, icon }
}, [inProgressSummary])
const responseDelta = useMemo(() => {
if (!dashboard?.firstResponse) return { delta: null, label: "Sem dados", positive: false }
const delta = dashboard.firstResponse.deltaMinutes
if (delta === null) return { delta: null, label: "Sem comparação", positive: false }
const positive = delta <= 0
const value = `${delta > 0 ? "+" : ""}${Math.round(delta)} min`
return { delta, label: value, positive }
}, [dashboard])
const TrendIcon = trendInfo.icon
const resolutionInfo = useMemo(() => {
if (!dashboard?.resolution) {
return {
positive: true,
badgeLabel: "Sem histórico",
rateLabel: "Taxa indisponível",
}
}
const current = dashboard?.resolution?.resolvedLast7d ?? 0
const previous = dashboard?.resolution?.previousResolved ?? 0
const deltaPercentage = dashboard?.resolution?.deltaPercentage ?? null
const positive = deltaPercentage !== null ? deltaPercentage >= 0 : current >= previous
const badgeLabel = deltaPercentage !== null
? `${deltaPercentage >= 0 ? "+" : ""}${deltaPercentage.toFixed(1)}%`
: previous > 0
? `${current - previous >= 0 ? "+" : ""}${current - previous}`
: "Sem histórico"
const rate = dashboard?.resolution?.rate ?? null
const rateLabel = rate !== null
? `${rate.toFixed(1)}% dos tickets foram resolvidos`
: "Taxa indisponível"
return { positive, badgeLabel, rateLabel }
}, [dashboard])
return (
<div className="grid grid-cols-1 gap-4 px-4 sm:grid-cols-2 xl:grid-cols-4 xl:px-8">
<Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm">
<CardHeader className="gap-3">
<CardDescription>Tickets em atendimento</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">
{inProgressSummary ? inProgressSummary.current : <Skeleton className="h-8 w-20" />}
</CardTitle>
<CardAction>
<Badge
variant="outline"
className={`rounded-full gap-1 px-2 py-1 text-xs ${
trendInfo.value !== null && trendInfo.value < 0 ? "text-red-500" : ""
}`}
>
<TrendIcon className="size-3.5" />
{trendInfo.label}
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground">
<div className="flex gap-2 text-foreground">
{trendInfo.value === null
? "Sem histórico recente"
: trendInfo.value >= 0
? "Acima das últimas 24h"
: "Abaixo das últimas 24h"}
</div>
<span>Base: tickets ativos (1ª resposta registrada) nas últimas 24h.</span>
</CardFooter>
</Card>
<Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm">
<CardHeader className="gap-3">
<CardDescription className="leading-snug">
<span className="block">Tempo médio da</span>
<span className="block">1ª resposta</span>
</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">
{dashboard?.firstResponse
? formatMinutes(dashboard.firstResponse.averageMinutes)
: <Skeleton className="h-8 w-24" />}
</CardTitle>
<CardAction>
<Badge
variant="outline"
className={`rounded-full gap-1 px-2 py-1 text-xs ${
responseDelta.delta !== null && !responseDelta.positive ? "text-amber-500" : ""
}`}
>
{responseDelta.delta !== null && responseDelta.delta > 0 ? (
<IconTrendingUp className="size-3.5" />
) : (
<IconTrendingDown className="size-3.5" />
)}
{responseDelta.label}
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground">
<span className="text-foreground">
{dashboard?.firstResponse
? `${dashboard.firstResponse.responsesCount} tickets com primeira resposta`
: "Carregando amostra"}
</span>
<span>Média móvel dos últimos 7 dias.</span>
</CardFooter>
</Card>
<Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm">
<CardHeader className="gap-3">
<CardDescription>Tickets aguardando ação</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">
{dashboard?.awaitingAction ? dashboard.awaitingAction.total : <Skeleton className="h-8 w-16" />}
</CardTitle>
<CardAction>
<Badge variant="outline" className="rounded-full gap-1 px-2 py-1 text-xs">
<IconClockHour4 className="size-3.5" />
{dashboard?.awaitingAction ? `${dashboard.awaitingAction.atRisk} em risco` : "—"}
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground">
<span className="text-foreground">Inclui status "Novo", "Aberto" e "Em espera".</span>
<span>Atrasos calculados com base no prazo de SLA.</span>
</CardFooter>
</Card>
<Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm">
<CardHeader className="gap-3">
<CardDescription className="leading-snug">
<span className="block">Tickets resolvidos</span>
<span className="block">(7 dias)</span>
</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">
{dashboard?.resolution ? dashboard.resolution.resolvedLast7d : <Skeleton className="h-8 w-12" />}
</CardTitle>
<CardAction>
<Badge
variant="outline"
className={cn(
"rounded-full gap-1 px-2 py-1 text-xs",
resolutionInfo.positive ? "text-emerald-600" : "text-amber-600"
)}
>
{resolutionInfo.positive ? (
<IconTrendingUp className="size-3.5" />
) : (
<IconTrendingDown className="size-3.5" />
)}
{resolutionInfo.badgeLabel}
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground">
<span className="text-foreground">{resolutionInfo.rateLabel}</span>
<span>Comparação com os 7 dias anteriores.</span>
</CardFooter>
</Card>
</div>
)
}

View file

@ -1,6 +1,7 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"
import { CheckCircle2, Eraser } from "lucide-react"
import { useMutation, useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
@ -15,6 +16,7 @@ import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import type { TicketStatus } from "@/lib/schemas/ticket"
import { cn } from "@/lib/utils"
type ClosingTemplate = { id: string; title: string; body: string }
@ -83,6 +85,30 @@ const DEFAULT_CLOSING_TEMPLATES: ClosingTemplate[] = [
},
]
const WIZARD_STEPS = [
{ key: "message", title: "Mensagem", description: "Personalize o texto enviado ao cliente." },
{ key: "time", title: "Tempo", description: "Revise e ajuste o esforço registrado." },
{ key: "confirm", title: "Confirmações", description: "Vincule tickets e defina reabertura." },
] as const
type WizardStepKey = (typeof WIZARD_STEPS)[number]["key"]
const DRAFT_STORAGE_PREFIX = "close-ticket-draft:"
type CloseTicketDraft = {
selectedTemplateId: string | null
message: string
shouldAdjustTime: boolean
internalHours: string
internalMinutes: string
externalHours: string
externalMinutes: string
adjustReason: string
linkedReference: string
reopenWindowDays: string
step: number
}
function applyTemplatePlaceholders(html: string, customerName?: string | null, agentName?: string | null) {
const normalizedCustomer = customerName?.trim()
const customerFallback = normalizedCustomer && normalizedCustomer.length > 0 ? normalizedCustomer : "cliente"
@ -196,6 +222,11 @@ export function CloseTicketDialog({
const linkedReferenceInputRef = useRef<HTMLInputElement | null>(null)
const suggestionHideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [reopenWindowDays, setReopenWindowDays] = useState<string>("14")
const [currentStep, setCurrentStep] = useState(0)
const [draftLoaded, setDraftLoaded] = useState(false)
const [hasStoredDraft, setHasStoredDraft] = useState(false)
const draftStorageKey = useMemo(() => `${DRAFT_STORAGE_PREFIX}${ticketId}`, [ticketId])
const digitsOnlyReference = linkedReference.replace(/[^0-9]/g, "").trim()
@ -223,8 +254,7 @@ export function CloseTicketDialog({
return stripLeadingEmptyParagraphs(sanitizeEditorHtml(withPlaceholders))
}, [requesterName, agentName])
useEffect(() => {
if (open) return
const resetFormState = useCallback(() => {
setSelectedTemplateId(null)
setMessage("")
setIsSubmitting(false)
@ -238,7 +268,99 @@ export function CloseTicketDialog({
setLinkedTicketSelection(null)
setLinkSuggestions([])
setShowLinkSuggestions(false)
}, [open])
setReopenWindowDays("14")
setCurrentStep(0)
}, [])
const loadDraft = useCallback(() => {
if (typeof window === "undefined") {
setHasStoredDraft(false)
return
}
const stored = window.localStorage.getItem(draftStorageKey)
if (!stored) {
setHasStoredDraft(false)
setCurrentStep(0)
resetFormState()
return
}
try {
const parsed = JSON.parse(stored) as CloseTicketDraft
setSelectedTemplateId(parsed.selectedTemplateId ?? null)
setMessage(parsed.message ?? "")
setShouldAdjustTime(Boolean(parsed.shouldAdjustTime))
setInternalHours(parsed.internalHours ?? "0")
setInternalMinutes(parsed.internalMinutes ?? "0")
setExternalHours(parsed.externalHours ?? "0")
setExternalMinutes(parsed.externalMinutes ?? "0")
setAdjustReason(parsed.adjustReason ?? "")
setLinkedReference(parsed.linkedReference ?? "")
setLinkedTicketSelection(null)
setLinkSuggestions([])
setShowLinkSuggestions(false)
setReopenWindowDays(parsed.reopenWindowDays ?? "14")
setCurrentStep(Math.min(parsed.step ?? 0, WIZARD_STEPS.length - 1))
setHasStoredDraft(true)
} catch (error) {
console.error("Erro ao carregar rascunho de encerramento", error)
window.localStorage.removeItem(draftStorageKey)
setHasStoredDraft(false)
resetFormState()
}
}, [draftStorageKey, resetFormState])
const handleSaveDraft = useCallback(() => {
if (typeof window === "undefined") return
const payload: CloseTicketDraft = {
selectedTemplateId,
message,
shouldAdjustTime,
internalHours,
internalMinutes,
externalHours,
externalMinutes,
adjustReason,
linkedReference,
reopenWindowDays,
step: currentStep,
}
window.localStorage.setItem(draftStorageKey, JSON.stringify(payload))
setHasStoredDraft(true)
toast.success("Rascunho salvo.")
}, [
adjustReason,
currentStep,
draftStorageKey,
externalHours,
externalMinutes,
internalHours,
internalMinutes,
linkedReference,
message,
reopenWindowDays,
selectedTemplateId,
shouldAdjustTime,
])
const handleDiscardDraft = useCallback(() => {
if (typeof window !== "undefined") {
window.localStorage.removeItem(draftStorageKey)
}
setHasStoredDraft(false)
resetFormState()
toast.success("Rascunho descartado.")
}, [draftStorageKey, resetFormState])
useEffect(() => {
if (open && !draftLoaded) {
loadDraft()
setDraftLoaded(true)
}
if (!open && draftLoaded) {
setDraftLoaded(false)
resetFormState()
}
}, [open, draftLoaded, loadDraft, resetFormState])
useEffect(() => {
return () => {
@ -403,6 +525,25 @@ export function CloseTicketDialog({
}
}
const hasFormChanges =
Boolean(message.trim().length) ||
Boolean(selectedTemplateId) ||
shouldAdjustTime ||
adjustReason.trim().length > 0 ||
linkedReference.trim().length > 0 ||
reopenWindowDays !== "14"
const canSaveDraft = hasFormChanges && !isSubmitting
const goToStep = (index: number) => {
if (index < 0 || index >= WIZARD_STEPS.length) return
setCurrentStep(index)
}
const goToNextStep = () => setCurrentStep((prev) => Math.min(prev + 1, WIZARD_STEPS.length - 1))
const goToPreviousStep = () => setCurrentStep((prev) => Math.max(prev - 1, 0))
const isLastStep = currentStep === WIZARD_STEPS.length - 1
const handleSubmit = async () => {
if (!actorId) {
toast.error("É necessário estar autenticado para encerrar o ticket.")
@ -502,6 +643,10 @@ export function CloseTicketDialog({
})
}
toast.success("Ticket encerrado com sucesso!", { id: "close-ticket" })
if (typeof window !== "undefined") {
window.localStorage.removeItem(draftStorageKey)
}
setHasStoredDraft(false)
onOpenChange(false)
onSuccess()
} catch (error) {
@ -514,52 +659,143 @@ export function CloseTicketDialog({
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<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>
const renderStepContent = () => {
const stepKey = WIZARD_STEPS[currentStep]?.key ?? "message"
if (stepKey === "time") {
if (!enableAdjustment) {
return (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-sm text-neutral-600">
Os ajustes de tempo não estão disponíveis para o seu perfil. Apenas administradores e agentes podem alterar o tempo registrado
antes de encerrar um ticket.
</div>
)
}
return (
<div className="rounded-2xl border border-slate-200 bg-white px-4 py-5 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-neutral-900">Ajustar tempo antes de encerrar</p>
<p className="text-xs text-neutral-500">
Atualize o tempo registrado e informe um motivo para o ajuste. O comentário fica visível apenas para a equipe.
</p>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="toggle-time-adjustment"
checked={shouldAdjustTime}
onCheckedChange={(checked) => setShouldAdjustTime(Boolean(checked))}
disabled={isSubmitting}
/>
<Label htmlFor="toggle-time-adjustment" className="text-sm font-medium text-neutral-800">
Incluir ajuste
</Label>
</div>
</div>
{shouldAdjustTime ? (
<div className="mt-4 space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Tempo interno</p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="adjust-internal-hours" className="text-xs text-neutral-600">
Horas
</Label>
<Input
id="adjust-internal-hours"
type="number"
min={0}
step={1}
inputMode="numeric"
value={internalHours}
onChange={(event) => setInternalHours(event.target.value)}
disabled={isSubmitting}
/>
</div>
<div className="space-y-1">
<Label htmlFor="adjust-internal-minutes" className="text-xs text-neutral-600">
Minutos
</Label>
<Input
id="adjust-internal-minutes"
type="number"
min={0}
max={59}
step={1}
inputMode="numeric"
value={internalMinutes}
onChange={(event) => setInternalMinutes(event.target.value)}
disabled={isSubmitting}
/>
</div>
</div>
<p className="text-xs text-neutral-500">Atual: {formatDurationLabel(workSummary?.internalWorkedMs ?? 0)}</p>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Tempo externo</p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="adjust-external-hours" className="text-xs text-neutral-600">
Horas
</Label>
<Input
id="adjust-external-hours"
type="number"
min={0}
step={1}
inputMode="numeric"
value={externalHours}
onChange={(event) => setExternalHours(event.target.value)}
disabled={isSubmitting}
/>
</div>
<div className="space-y-1">
<Label htmlFor="adjust-external-minutes" className="text-xs text-neutral-600">
Minutos
</Label>
<Input
id="adjust-external-minutes"
type="number"
min={0}
max={59}
step={1}
inputMode="numeric"
value={externalMinutes}
onChange={(event) => setExternalMinutes(event.target.value)}
disabled={isSubmitting}
/>
</div>
</div>
<p className="text-xs text-neutral-500">Atual: {formatDurationLabel(workSummary?.externalWorkedMs ?? 0)}</p>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="adjust-reason" className="text-xs text-neutral-600">
Motivo do ajuste
</Label>
<Textarea
id="adjust-reason"
value={adjustReason}
onChange={(event) => setAdjustReason(event.target.value)}
placeholder="Descreva por que o tempo precisa ser ajustado..."
rows={3}
disabled={isSubmitting}
/>
<p className="text-xs text-neutral-500">
Registre o motivo para fins de auditoria interna. Informe valores em minutos quando menor que 1 hora.
</p>
</div>
</div>
) : null}
</div>
)
}
if (stepKey === "confirm") {
return (
<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 className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
<div className="rounded-2xl border border-slate-200 bg-white px-4 py-5 shadow-sm">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
<div className="space-y-2">
<Label htmlFor="linked-reference" className="text-sm font-medium text-neutral-800">
@ -596,9 +832,7 @@ export function CloseTicketDialog({
onClick={() => handleSelectLinkSuggestion(suggestion)}
className="flex w-full flex-col gap-1 px-3 py-2 text-left text-sm transition hover:bg-slate-100 focus:bg-slate-100"
>
<span className="font-semibold text-neutral-900">
#{suggestion.reference}
</span>
<span className="font-semibold text-neutral-900">#{suggestion.reference}</span>
{suggestion.subject ? (
<span className="text-xs text-neutral-600">{suggestion.subject}</span>
) : null}
@ -617,10 +851,13 @@ export function CloseTicketDialog({
<Spinner className="size-3" /> Procurando ticket #{normalizedReference}...
</p>
) : linkNotFound ? (
<p className="text-xs text-red-500">Ticket não encontrado ou sem acesso permitido. Verifique o número informado.</p>
<p className="text-xs text-red-500">
Ticket não encontrado ou sem acesso permitido. Verifique o número informado.
</p>
) : linkedTicketCandidate ? (
<p className="text-xs text-emerald-600">
Será registrado vínculo com o ticket #{linkedTicketCandidate.reference} {linkedTicketCandidate.subject ?? "Sem assunto"}
Será registrado vínculo com o ticket #{linkedTicketCandidate.reference} {" "}
{linkedTicketCandidate.subject ?? "Sem assunto"}
</p>
) : null}
</div>
@ -642,157 +879,163 @@ export function CloseTicketDialog({
</div>
</div>
</div>
{enableAdjustment ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-neutral-800">Ajustar tempo antes de encerrar</p>
<p className="text-xs text-neutral-500">
Atualize o tempo registrado e informe um motivo para o ajuste. O comentário fica visível apenas para a equipe.
</p>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="toggle-time-adjustment"
checked={shouldAdjustTime}
onCheckedChange={(checked) => setShouldAdjustTime(Boolean(checked))}
disabled={isSubmitting}
/>
<Label htmlFor="toggle-time-adjustment" className="text-sm font-medium text-neutral-800">
Incluir ajuste
</Label>
</div>
)
}
return (
<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>
{shouldAdjustTime ? (
<div className="mt-4 space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Tempo interno</p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="adjust-internal-hours" className="text-xs text-neutral-600">
Horas
</Label>
<Input
id="adjust-internal-hours"
type="number"
min={0}
step={1}
inputMode="numeric"
value={internalHours}
onChange={(event) => setInternalHours(event.target.value)}
disabled={isSubmitting}
/>
</div>
<div className="space-y-1">
<Label htmlFor="adjust-internal-minutes" className="text-xs text-neutral-600">
Minutos
</Label>
<Input
id="adjust-internal-minutes"
type="number"
min={0}
max={59}
step={1}
inputMode="numeric"
value={internalMinutes}
onChange={(event) => setInternalMinutes(event.target.value)}
disabled={isSubmitting}
/>
</div>
</div>
<p className="text-xs text-neutral-500">
Atual: {formatDurationLabel(workSummary?.internalWorkedMs ?? 0)}
</p>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Tempo externo</p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="adjust-external-hours" className="text-xs text-neutral-600">
Horas
</Label>
<Input
id="adjust-external-hours"
type="number"
min={0}
step={1}
inputMode="numeric"
value={externalHours}
onChange={(event) => setExternalHours(event.target.value)}
disabled={isSubmitting}
/>
</div>
<div className="space-y-1">
<Label htmlFor="adjust-external-minutes" className="text-xs text-neutral-600">
Minutos
</Label>
<Input
id="adjust-external-minutes"
type="number"
min={0}
max={59}
step={1}
inputMode="numeric"
value={externalMinutes}
onChange={(event) => setExternalMinutes(event.target.value)}
disabled={isSubmitting}
/>
</div>
</div>
<p className="text-xs text-neutral-500">
Atual: {formatDurationLabel(workSummary?.externalWorkedMs ?? 0)}
</p>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="adjust-reason" className="text-xs text-neutral-600">
Motivo do ajuste
</Label>
<Textarea
id="adjust-reason"
value={adjustReason}
onChange={(event) => setAdjustReason(event.target.value)}
placeholder="Descreva por que o tempo precisa ser ajustado..."
rows={3}
disabled={isSubmitting}
/>
<p className="text-xs text-neutral-500">
Registre o motivo para fins de auditoria interna. Informe valores em minutos quando menor que 1 hora.
</p>
</div>
</div>
) : null}
</div>
) : null}
<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="ghost"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
) : (
<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)}
disabled={isSubmitting}
>
{template.title}
</Button>
))}
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-neutral-500">
<span>
Use <code className="rounded bg-slate-100 px-1 py-0.5">{"{{cliente}}"}</code> dentro do template para inserir automaticamente o nome do solicitante.
</span>
<Button
type="button"
variant="outline"
size="icon"
title="Limpar mensagem"
aria-label="Limpar mensagem"
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"}
<Eraser className="size-4" />
<span className="sr-only">Limpar mensagem</span>
</Button>
</div>
</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. O comentário será público e ficará registrado no histórico do ticket.
</p>
</div>
</div>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Encerrar ticket</DialogTitle>
<DialogDescription>
Complete as etapas abaixo para encerrar o ticket e registrar o comunicado final.
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap">
{WIZARD_STEPS.map((step, index) => {
const isActive = index === currentStep
const isCompleted = index < currentStep
const canNavigate = index <= currentStep
return (
<div
key={step.key}
className="flex min-w-[200px] flex-1 items-center gap-3"
>
<button
type="button"
onClick={() => (canNavigate && !isSubmitting ? goToStep(index) : undefined)}
disabled={!canNavigate || isSubmitting}
className={cn(
"flex size-9 items-center justify-center rounded-full border text-sm font-semibold transition",
isCompleted
? "border-emerald-500 bg-emerald-500 text-white"
: isActive
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white text-neutral-500"
)}
>
{isCompleted ? <CheckCircle2 className="size-4" /> : index + 1}
</button>
<div>
<p className={cn("text-sm font-semibold", isActive ? "text-neutral-900" : "text-neutral-600")}>
{step.title}
</p>
<p className="text-xs text-neutral-500">{step.description}</p>
</div>
</div>
)
})}
</div>
</div>
{renderStepContent()}
</div>
<DialogFooter className="mt-4 w-full border-t border-slate-100 pt-4">
{hasStoredDraft ? (
<div className="w-full text-xs text-neutral-500">Rascunho salvo localmente.</div>
) : null}
<div className="flex w-full flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button
type="button"
variant="outline"
onClick={handleSaveDraft}
disabled={!canSaveDraft}
>
Salvar rascunho
</Button>
{hasStoredDraft ? (
<Button type="button" variant="ghost" onClick={handleDiscardDraft} disabled={isSubmitting}>
Descartar rascunho
</Button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{currentStep > 0 ? (
<Button type="button" variant="outline" onClick={goToPreviousStep} disabled={isSubmitting}>
Voltar
</Button>
) : null}
{!isLastStep ? (
<Button type="button" onClick={goToNextStep} disabled={isSubmitting}>
Próximo passo
</Button>
) : (
<Button type="button" onClick={handleSubmit} disabled={isSubmitting || !actorId}>
{isSubmitting ? <Spinner className="size-4 text-white" /> : "Encerrar ticket"}
</Button>
)}
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>

View file

@ -0,0 +1,171 @@
"use client"
import { useMemo, useState } from "react"
import Link from "next/link"
import { useQuery } from "convex/react"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { Inbox, ChevronLeft, ChevronRight } from "lucide-react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
import type { Ticket } from "@/lib/schemas/ticket"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { TicketStatusBadge } from "@/components/tickets/status-badge"
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
const PAGE_SIZE = 4
export function MyTicketsPanel() {
const { convexUserId, isStaff, session } = useAuth()
const tenantId = session?.user?.tenantId ?? DEFAULT_TENANT_ID
const [page, setPage] = useState(0)
const enabled = Boolean(convexUserId && isStaff)
const ticketsResult = useQuery(
api.tickets.list,
enabled
? {
tenantId,
viewerId: convexUserId as Id<"users">,
assigneeId: convexUserId as Id<"users">,
limit: 60,
}
: "skip"
)
const tickets = useMemo(() => {
if (!Array.isArray(ticketsResult)) return []
const parsed = mapTicketsFromServerList(ticketsResult as unknown[]).filter(
(ticket) => ticket.status !== "RESOLVED"
)
return parsed
}, [ticketsResult])
const totalPages = Math.max(1, Math.ceil(tickets.length / PAGE_SIZE))
const currentPage = Math.min(page, totalPages - 1)
const paginated = tickets.slice(currentPage * PAGE_SIZE, currentPage * PAGE_SIZE + PAGE_SIZE)
return (
<Card className="rounded-3xl border border-border/60 shadow-sm">
<CardHeader>
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-base font-semibold">Minhas tarefas</CardTitle>
<CardDescription>Chamados atribuídos a você e ainda em progresso</CardDescription>
</div>
{convexUserId ? (
<Link
href={`/tickets?assignee=${String(convexUserId)}`}
className="text-sm font-semibold text-[#006879] underline-offset-4 transition-colors hover:text-[#004d5a] hover:underline"
>
Ver todos
</Link>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-3">
{!enabled ? (
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-border/60 bg-muted/40 px-4 py-6 text-center text-sm text-muted-foreground">
<Inbox className="size-6" />
<p>Disponível apenas para usuários internos.</p>
</div>
) : ticketsResult === undefined ? (
<div className="space-y-2">
{Array.from({ length: PAGE_SIZE }).map((_, index) => (
<Skeleton key={`mytickets-skeleton-${index}`} className="h-20 w-full rounded-2xl" />
))}
</div>
) : paginated.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/60 bg-muted/40 px-4 py-6 text-center text-sm text-muted-foreground">
Nenhum ticket atribuído para hoje. Aproveite para ajudar na triagem ou revisar filas em risco.
</div>
) : (
<div className="space-y-3">
{paginated.map((ticket) => (
<TicketRow key={ticket.id} ticket={ticket} />
))}
</div>
)}
</CardContent>
{enabled && tickets.length > PAGE_SIZE ? (
<CardFooter className="flex items-center justify-between border-t border-border/60 px-6 py-4 text-sm text-muted-foreground">
<span>
Página {currentPage + 1} de {totalPages}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() => setPage((prev) => Math.max(0, prev - 1))}
disabled={currentPage === 0}
>
<ChevronLeft className="size-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => setPage((prev) => Math.min(totalPages - 1, prev + 1))}
disabled={currentPage >= totalPages - 1}
>
<ChevronRight className="size-4" />
</Button>
</div>
</CardFooter>
) : null}
</Card>
)
}
function TicketRow({ ticket }: { ticket: Ticket }) {
const queueLabel = ticket.queue ?? "Sem fila"
const updatedLabel = formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })
const categoryBadges = [ticket.category?.name, ticket.subcategory?.name].filter(
(value): value is string => Boolean(value)
)
const badgeClass =
"rounded-lg border border-slate-300 px-3.5 py-1.5 text-sm font-medium text-slate-600 transition-colors"
return (
<Link
href={`/tickets/${ticket.id}`}
className="group relative flex flex-col gap-3 rounded-2xl border border-border/70 bg-white/90 px-5 py-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-border hover:shadow-md"
>
<div className="absolute right-5 top-4 flex flex-col items-end gap-2 sm:flex-row sm:items-center">
<TicketStatusBadge status={ticket.status} className="h-8 px-3.5 text-sm" />
<TicketPriorityPill priority={ticket.priority} className="h-8 px-3.5 text-sm" />
</div>
<div className="flex flex-col gap-2 pr-0">
<div className="flex flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
#{ticket.reference} {queueLabel}
</span>
<span className="line-clamp-1 pr-32 text-base font-semibold text-neutral-900">{ticket.subject}</span>
</div>
<p className="line-clamp-2 pr-32 text-sm text-neutral-600">{ticket.summary ?? "Sem descrição"}</p>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-neutral-600">
{categoryBadges.length > 0 ? (
categoryBadges.map((label) => (
<span key={label} className={badgeClass}>
{label}
</span>
))
) : (
<span className={badgeClass}>Sem categoria</span>
)}
<span className={badgeClass}>Atualizado {updatedLabel}</span>
</div>
</Link>
)
}

View file

@ -2,12 +2,14 @@
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { NewTicketDialog } from "./new-ticket-dialog"
export function NewTicketDialogDeferred({ triggerClassName }: { triggerClassName?: string } = {}) {
type DeferredProps = {
triggerClassName?: string
triggerVariant?: "button" | "card"
}
export function NewTicketDialogDeferred({ triggerClassName, triggerVariant = "button" }: DeferredProps = {}) {
const [mounted, setMounted] = useState(false)
useEffect(() => {
@ -16,19 +18,15 @@ export function NewTicketDialogDeferred({ triggerClassName }: { triggerClassName
if (!mounted) {
return (
<Button
size="sm"
className={cn(
"rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30",
triggerClassName
)}
disabled
aria-disabled
>
Novo ticket
</Button>
<div
className={
triggerVariant === "card"
? `h-28 min-w-[220px] rounded-2xl border border-slate-900 bg-neutral-900/80 text-white shadow-sm ${triggerClassName ?? ""}`
: `rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white opacity-60 ${triggerClassName ?? ""}`
}
/>
)
}
return <NewTicketDialog triggerClassName={triggerClassName} />
return <NewTicketDialog triggerClassName={triggerClassName} triggerVariant={triggerVariant} />
}

View file

@ -36,6 +36,8 @@ import { normalizeCustomFieldInputs } from "@/lib/ticket-form-helpers"
import type { TicketFormDefinition, TicketFormFieldDefinition } from "@/lib/ticket-form-types"
import { Calendar as CalendarIcon } from "lucide-react"
type TriggerVariant = "button" | "card"
type CustomerOption = {
id: string
name: string
@ -113,7 +115,13 @@ const schema = z.object({
subcategoryId: z.string().min(1, "Selecione uma categoria secundária"),
})
export function NewTicketDialog({ triggerClassName }: { triggerClassName?: string } = {}) {
export function NewTicketDialog({
triggerClassName,
triggerVariant = "button",
}: {
triggerClassName?: string
triggerVariant?: TriggerVariant
} = {}) {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const calendarTimeZone = useLocalTimeZone()
@ -558,18 +566,41 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
}
}
const cardTrigger = (
<button
type="button"
className={cn(
"rounded-2xl border border-slate-900 bg-neutral-950 px-4 py-4 text-left text-white shadow-lg shadow-black/30 transition hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40",
"flex h-28 min-w-[220px] flex-1 flex-col justify-between",
triggerClassName
)}
>
<span className="text-xs font-semibold uppercase tracking-wide text-white/60">Atalho</span>
<div className="space-y-1">
<p className="text-lg font-semibold leading-tight">Novo ticket</p>
<p className="text-xs text-white/70">Abrir chamado manualmente</p>
</div>
</button>
)
const buttonTrigger = (
<Button
size="sm"
className={cn(
"rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30",
triggerClassName
)}
>
Novo ticket
</Button>
)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
size="sm"
className={cn(
"rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30",
triggerClassName
)}
>
Novo ticket
</Button>
{triggerVariant === "card"
? cardTrigger
: buttonTrigger}
</DialogTrigger>
<DialogContent className="max-w-4xl gap-0 overflow-hidden rounded-3xl border border-slate-200 bg-white p-0 shadow-2xl lg:max-w-5xl">
<div className="max-h-[88vh] overflow-y-auto">

View file

@ -1,6 +1,9 @@
"use client";
import { useEffect, useState } from "react";
import { useQuery } from "convex/react";
import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import { api } from "@/convex/_generated/api";
import { DEFAULT_TENANT_ID } from "@/lib/constants";
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket";
@ -8,6 +11,7 @@ import type { Id } from "@/convex/_generated/dataModel";
import type { TicketWithDetails } from "@/lib/schemas/ticket";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { TicketComments } from "@/components/tickets/ticket-comments.rich";
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
@ -89,8 +93,16 @@ export function TicketDetailView({ id }: { id: string }) {
</div>
);
}
const handlePrioritizeClick = () => {
if (typeof window === "undefined") return;
const anchor = document.getElementById("ticket-summary-header");
anchor?.scrollIntoView({ behavior: "smooth", block: "start" });
};
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketSlaBanner ticket={ticket} onPrioritize={handlePrioritizeClick} />
<TicketSummaryHeader ticket={ticket} />
<TicketCsatCard ticket={ticket} />
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
@ -104,3 +116,51 @@ export function TicketDetailView({ id }: { id: string }) {
</div>
);
}
const SLA_WARNING_THRESHOLD_MS = 1000 * 60 * 60 * 4;
function TicketSlaBanner({ ticket, onPrioritize }: { ticket: TicketWithDetails; onPrioritize: () => void }) {
const [now, setNow] = useState(Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 60000);
return () => clearInterval(id);
}, []);
const dueAtDate = ticket.dueAt ? new Date(ticket.dueAt) : null;
if (!dueAtDate || ticket.status === "RESOLVED") {
return null;
}
const diff = dueAtDate.getTime() - now;
const isOverdue = diff <= 0;
const isNearThreshold = diff > 0 && diff <= SLA_WARNING_THRESHOLD_MS;
if (!isOverdue && !isNearThreshold) {
return null;
}
const label = isOverdue
? `SLA vencido ${formatDistanceToNow(dueAtDate, { addSuffix: true, locale: ptBR })}`
: `Faltam ${formatDistanceToNow(dueAtDate, { locale: ptBR })} para o SLA`;
const containerClasses = isOverdue
? "border-rose-200 bg-rose-50 text-rose-900"
: "border-amber-200 bg-amber-50 text-amber-900";
const buttonVariant = isOverdue ? "destructive" : "outline";
return (
<div
className={`flex flex-col gap-3 rounded-2xl border px-4 py-4 shadow-sm sm:flex-row sm:items-center sm:justify-between ${containerClasses}`}
>
<div>
<p className="text-sm font-semibold">{isOverdue ? "SLA em atraso" : "SLA em risco"}</p>
<p className="text-sm">{label}</p>
</div>
<Button variant={buttonVariant as "destructive" | "outline"} onClick={onPrioritize} className="w-full sm:w-auto">
Priorizar atendimento
</Button>
</div>
);
}

View file

@ -1081,7 +1081,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
}, [reopenTicket, ticket.id, viewerId])
return (
<div className={cardClass}>
<div id="ticket-summary-header" className={cardClass}>
<div className="absolute right-6 top-6 flex items-center gap-3">
<Button
type="button"

View file

@ -1,13 +1,14 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }
function Skeleton({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="skeleton"
role="presentation"
className={cn("block bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }