feat(calendar): migrate to react-day-picker v9 and polish UI - Update classNames and CSS import (style.css) - Custom Dropdown via shadcn Select - Nav arrows aligned with caption (around) - Today highlight with cyan tone, weekdays in sentence case - Wider layout to avoid overflow; remove inner wrapper chore(tickets): make 'Patrimônio do computador (se houver)' optional - Backend hotfix to enforce optional + label on existing tenants - Hide required asterisk for this field in portal/new-ticket refactor(new-ticket): remove channel dropdown from admin/agent flow - Keep default channel as MANUAL feat(ux): simplify requester section and enlarge combobox trigger - Remove RequesterPreview redundancy; show company badge in trigger
133 lines
5.2 KiB
TypeScript
133 lines
5.2 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import { CartesianGrid, Line, LineChart, 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, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
|
import { formatDateDM, formatDateDMY } from "@/lib/utils"
|
|
import { Skeleton } from "@/components/ui/skeleton"
|
|
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
|
|
|
type SeriesPoint = { date: string; opened: number; resolved: number }
|
|
|
|
const chartConfig = {
|
|
opened: { label: "Abertos", color: "var(--chart-1)" },
|
|
resolved: { label: "Resolvidos", color: "var(--chart-2)" },
|
|
} as const
|
|
|
|
export function ChartOpenedResolved() {
|
|
const [timeRange, setTimeRange] = React.useState("30d")
|
|
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
|
const { session, convexUserId, isStaff } = useAuth()
|
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
|
|
|
const reportsEnabled = Boolean(isStaff && convexUserId)
|
|
const data = useQuery(
|
|
api.reports.openedResolvedByDay,
|
|
reportsEnabled
|
|
? ({
|
|
tenantId,
|
|
viewerId: convexUserId as Id<"users">,
|
|
range: timeRange,
|
|
companyId: companyId === "all" ? undefined : (companyId as Id<"companies">),
|
|
})
|
|
: "skip"
|
|
) as { rangeDays: number; series: SeriesPoint[] } | undefined
|
|
|
|
const companies = useQuery(
|
|
api.companies.list,
|
|
reportsEnabled ? { 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" />
|
|
}
|
|
|
|
return (
|
|
<Card className="@container/card">
|
|
<CardHeader>
|
|
<CardTitle>Abertos x Resolvidos</CardTitle>
|
|
<CardDescription>
|
|
Evolução diária nos últimos {data.rangeDays} dias
|
|
</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 @[767px]/card: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="px-2 pt-4 sm:px-6 sm:pt-6">
|
|
{data.series.length === 0 ? (
|
|
<div className="flex h-[320px] items-center justify-center rounded-xl border border-dashed border-border/60 text-sm text-muted-foreground">
|
|
Sem dados suficientes no período selecionado.
|
|
</div>
|
|
) : (
|
|
<ChartContainer config={chartConfig} className="aspect-auto h-[320px] w-full">
|
|
<LineChart data={data.series} margin={{ top: 20, left: 16, right: 16, bottom: 12 }}>
|
|
<CartesianGrid vertical={false} />
|
|
<XAxis
|
|
dataKey="date"
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickMargin={8}
|
|
minTickGap={32}
|
|
tickFormatter={(v) => formatDateDM(new Date(v))}
|
|
/>
|
|
<ChartTooltip
|
|
cursor={false}
|
|
content={
|
|
<ChartTooltipContent
|
|
indicator="line"
|
|
labelFormatter={(value) => formatDateDMY(new Date(value as string))}
|
|
/>
|
|
}
|
|
/>
|
|
<Line dataKey="opened" type="monotone" stroke="var(--color-opened)" strokeWidth={2} dot={{ r: 2 }} strokeLinecap="round" />
|
|
<Line dataKey="resolved" type="monotone" stroke="var(--color-resolved)" strokeWidth={2} dot={{ r: 2 }} strokeLinecap="round" />
|
|
</LineChart>
|
|
</ChartContainer>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|