chore: sync staging
This commit is contained in:
parent
c5ddd54a3e
commit
561b19cf66
610 changed files with 105285 additions and 1206 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
122
src/components/dashboard/dashboard-alerts-panel.tsx
Normal file
122
src/components/dashboard/dashboard-alerts-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
483
src/components/dashboard/dashboard-hero.tsx
Normal file
483
src/components/dashboard/dashboard-hero.tsx
Normal 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 há 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
116
src/components/global-quick-actions.tsx
Normal file
116
src/components/global-quick-actions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
678
src/components/incidents/incident-workspace.tsx
Normal file
678
src/components/incidents/incident-workspace.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
111
src/components/reports/report-filter-toolbar.tsx
Normal file
111
src/components/reports/report-filter-toolbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
553
src/components/reports/report-schedule-drawer.tsx
Normal file
553
src/components/reports/report-schedule-drawer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
171
src/components/tickets/my-tickets-panel.tsx
Normal file
171
src/components/tickets/my-tickets-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue