From 5b220656093e319dcdc84e950c41eb44fb0dae89 Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Fri, 14 Nov 2025 00:59:11 -0300 Subject: [PATCH] feat(reports): add date range filters and extend machine reports --- convex/reports.ts | 221 ++++++--- .../reports/report-templates-manager.tsx | 4 +- src/components/date-range-button.tsx | 102 ++++ src/components/reports/backlog-report.tsx | 29 +- src/components/reports/category-report.tsx | 20 +- src/components/reports/csat-report.tsx | 23 +- src/components/reports/hours-report.tsx | 442 +++++++++++++----- .../reports/machine-category-report.tsx | 27 +- .../reports/report-filter-toolbar.tsx | 31 +- src/components/reports/sla-report.tsx | 32 +- src/components/tickets/tickets-filters.tsx | 101 +--- 11 files changed, 742 insertions(+), 290 deletions(-) create mode 100644 src/components/date-range-button.tsx diff --git a/convex/reports.ts b/convex/reports.ts index 562c1c8..fa37ad9 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -109,6 +109,53 @@ function withUpperBound(range: T, field: string, value: number): T { return range; } +function parseIsoDateToMs(value?: string | null): number | null { + if (!value) return null; + const [yearStr, monthStr, dayStr] = value.split("-"); + const year = Number(yearStr); + const month = Number(monthStr); + const day = Number(dayStr); + if (!year || !month || !day) return null; + const date = new Date(Date.UTC(year, month - 1, day, 0, 0, 0, 0)); + return date.getTime(); +} + +function resolveRangeWindow( + range: string | undefined, + dateFrom: string | undefined, + dateTo: string | undefined, + defaultDays: number, +): { startMs: number; endMs: number; days: number } { + const fromMs = parseIsoDateToMs(dateFrom); + const toMs = parseIsoDateToMs(dateTo); + + if (fromMs !== null || toMs !== null) { + const startMs = fromMs ?? toMs ?? Date.now(); + const endMsBase = toMs ?? fromMs ?? startMs; + const endMs = endMsBase + ONE_DAY_MS; + const days = Math.max(1, Math.round((endMs - startMs) / ONE_DAY_MS)); + return { startMs, endMs, days }; + } + + const normalizedRange = range ?? "90d"; + let days = defaultDays; + if (normalizedRange === "7d") days = 7; + else if (normalizedRange === "30d") days = 30; + else if (normalizedRange === "365d" || normalizedRange === "12m") days = 365; + else if (normalizedRange === "all") { + const now = new Date(); + now.setUTCHours(0, 0, 0, 0); + const endMs = now.getTime() + ONE_DAY_MS; + return { startMs: 0, endMs, days: 0 }; + } + + const end = new Date(); + end.setUTCHours(0, 0, 0, 0); + const endMs = end.getTime() + ONE_DAY_MS; + const startMs = endMs - days * ONE_DAY_MS; + return { startMs, endMs, days }; +} + function resolveScopedCompanyId( viewer: Awaited>, companyId?: Id<"companies">, @@ -469,15 +516,24 @@ function formatDateKey(timestamp: number) { export async function slaOverviewHandler( ctx: QueryCtx, - { tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> } + { + tenantId, + viewerId, + range, + companyId, + dateFrom, + dateTo, + }: { + tenantId: string + viewerId: Id<"users"> + range?: string + companyId?: Id<"companies"> + dateFrom?: string + dateTo?: string + } ) { const viewer = await requireStaff(ctx, viewerId, tenantId); - // Optional range filter (createdAt) for reporting purposes, similar ao backlog/csat - const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; - const end = new Date(); - end.setUTCHours(0, 0, 0, 0); - const endMs = end.getTime() + ONE_DAY_MS; - const startMs = endMs - days * ONE_DAY_MS; + const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90); const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId); const queues = await fetchQueues(ctx, tenantId); const categoriesMap = await fetchCategoryMap(ctx, tenantId); @@ -616,14 +672,17 @@ export const triggerScheduledExports = action({ export async function csatOverviewHandler( ctx: QueryCtx, - { tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> } + { + tenantId, + viewerId, + range, + companyId, + dateFrom, + dateTo, + }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies">; dateFrom?: string; dateTo?: string } ) { const viewer = await requireStaff(ctx, viewerId, tenantId); - const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; - const end = new Date(); - end.setUTCHours(0, 0, 0, 0); - const endMs = end.getTime() + ONE_DAY_MS; - const startMs = endMs - days * ONE_DAY_MS; + const { startMs, endMs } = resolveRangeWindow(range, dateFrom, dateTo, 90); const tickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId); const surveys = (await collectCsatSurveys(ctx, tickets)).filter((s) => s.receivedAt >= startMs && s.receivedAt < endMs); @@ -700,27 +759,37 @@ export async function csatOverviewHandler( assigneeId: item.assigneeId, assigneeName: item.assigneeName, })), - rangeDays: days, + rangeDays: Math.max(1, Math.round((endMs - startMs) / ONE_DAY_MS)), positiveRate, byAgent, }; } export const csatOverview = query({ - args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) }, + args: { + tenantId: v.string(), + viewerId: v.id("users"), + range: v.optional(v.string()), + companyId: v.optional(v.id("companies")), + dateFrom: v.optional(v.string()), + dateTo: v.optional(v.string()), + }, handler: csatOverviewHandler, }); export async function openedResolvedByDayHandler( ctx: QueryCtx, - { tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> } + { + tenantId, + viewerId, + range, + companyId, + dateFrom, + dateTo, + }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies">; dateFrom?: string; dateTo?: string } ) { const viewer = await requireStaff(ctx, viewerId, tenantId); - const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; - const end = new Date(); - end.setUTCHours(0, 0, 0, 0); - const endMs = end.getTime() + ONE_DAY_MS; - const startMs = endMs - days * ONE_DAY_MS; + const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90); const openedTickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId); const resolvedTickets = await fetchScopedTicketsByResolvedRange(ctx, tenantId, viewer, startMs, endMs, companyId); @@ -761,21 +830,30 @@ export async function openedResolvedByDayHandler( } export const openedResolvedByDay = query({ - args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) }, + args: { + tenantId: v.string(), + viewerId: v.id("users"), + range: v.optional(v.string()), + companyId: v.optional(v.id("companies")), + dateFrom: v.optional(v.string()), + dateTo: v.optional(v.string()), + }, handler: openedResolvedByDayHandler, }) export async function backlogOverviewHandler( ctx: QueryCtx, - { tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> } + { + tenantId, + viewerId, + range, + companyId, + dateFrom, + dateTo, + }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies">; dateFrom?: string; dateTo?: string } ) { const viewer = await requireStaff(ctx, viewerId, tenantId); - // Optional range filter (createdAt) for reporting purposes - const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; - const end = new Date(); - end.setUTCHours(0, 0, 0, 0); - const endMs = end.getTime() + ONE_DAY_MS; - const startMs = endMs - days * ONE_DAY_MS; + const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90); const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId); const statusCounts = inRange.reduce>((acc, ticket) => { @@ -821,7 +899,14 @@ export async function backlogOverviewHandler( } export const backlogOverview = query({ - args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) }, + args: { + tenantId: v.string(), + viewerId: v.id("users"), + range: v.optional(v.string()), + companyId: v.optional(v.id("companies")), + dateFrom: v.optional(v.string()), + dateTo: v.optional(v.string()), + }, handler: backlogOverviewHandler, }); @@ -1023,14 +1108,12 @@ export async function ticketCategoryInsightsHandler( viewerId, range, companyId, - }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> } + dateFrom, + dateTo, + }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies">; dateFrom?: string; dateTo?: string } ) { const viewer = await requireStaff(ctx, viewerId, tenantId) - const days = range === "7d" ? 7 : range === "30d" ? 30 : 90 - const end = new Date() - end.setUTCHours(0, 0, 0, 0) - const endMs = end.getTime() + ONE_DAY_MS - const startMs = endMs - days * ONE_DAY_MS + const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90) const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId) const categories = await ctx.db @@ -1141,6 +1224,8 @@ export const categoryInsights = query({ viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")), + dateFrom: v.optional(v.string()), + dateTo: v.optional(v.string()), }, handler: ticketCategoryInsightsHandler, }) @@ -1347,14 +1432,17 @@ export async function ticketsByMachineAndCategoryHandler( } ) { const viewer = await requireStaff(ctx, viewerId, tenantId) - const days = range === "7d" ? 7 : range === "30d" ? 30 : 90 + const { startMs, endMs, days } = resolveRangeWindow(range, undefined, undefined, 90) - const end = new Date() - end.setUTCHours(0, 0, 0, 0) - const endMs = end.getTime() + ONE_DAY_MS - const startMs = endMs - days * ONE_DAY_MS - - const tickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId) + const tickets = + days === 0 + ? await fetchScopedTickets(ctx, tenantId, viewer).then((all) => + all.filter((ticket) => { + if (companyId && ticket.companyId && ticket.companyId !== companyId) return false + return true + }), + ) + : await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId) const categoriesMap = await fetchCategoryMap(ctx, tenantId) const companyIds = new Set>() @@ -1458,7 +1546,7 @@ export async function ticketsByMachineAndCategoryHandler( }) return { - rangeDays: days, + rangeDays: days === 0 ? -1 : days, items, } } @@ -1471,6 +1559,8 @@ export const ticketsByMachineAndCategory = query({ companyId: v.optional(v.id("companies")), machineId: v.optional(v.id("machines")), userId: v.optional(v.id("users")), + dateFrom: v.optional(v.string()), + dateTo: v.optional(v.string()), }, handler: ticketsByMachineAndCategoryHandler, }) @@ -1506,11 +1596,7 @@ export async function hoursByMachineHandler( const viewer = await requireStaff(ctx, viewerId, tenantId) const tickets = await fetchScopedTickets(ctx, tenantId, viewer) - const days = range === "7d" ? 7 : range === "30d" ? 30 : 90 - const end = new Date() - end.setUTCHours(0, 0, 0, 0) - const endMs = end.getTime() + ONE_DAY_MS - const startMs = endMs - days * ONE_DAY_MS + const { startMs, endMs, days } = resolveRangeWindow(range, undefined, undefined, 90) const machinesById = new Map | null>() const companiesById = new Map | null>() @@ -1518,7 +1604,7 @@ export async function hoursByMachineHandler( const map = new Map() for (const t of tickets) { - if (t.updatedAt < startMs || t.updatedAt >= endMs) continue + if (days !== 0 && (t.updatedAt < startMs || t.updatedAt >= endMs)) continue if (companyId && t.companyId && t.companyId !== companyId) continue if (machineId && t.machineId !== machineId) continue if (userId && t.requesterId !== userId) continue @@ -1588,7 +1674,7 @@ export async function hoursByMachineHandler( }) return { - rangeDays: days, + rangeDays: days === 0 ? -1 : days, items, } } @@ -1601,22 +1687,20 @@ export const hoursByMachine = query({ companyId: v.optional(v.id("companies")), machineId: v.optional(v.id("machines")), userId: v.optional(v.id("users")), + dateFrom: v.optional(v.string()), + dateTo: v.optional(v.string()), }, handler: hoursByMachineHandler, }) export async function hoursByClientHandler( ctx: QueryCtx, - { tenantId, viewerId, range }: { tenantId: string; viewerId: Id<"users">; range?: string } + { tenantId, viewerId, range, dateFrom, dateTo }: { tenantId: string; viewerId: Id<"users">; range?: string; dateFrom?: string; dateTo?: string } ) { const viewer = await requireStaff(ctx, viewerId, tenantId) const tickets = await fetchScopedTickets(ctx, tenantId, viewer) - const days = range === "7d" ? 7 : range === "30d" ? 30 : 90 - const end = new Date() - end.setUTCHours(0, 0, 0, 0) - const endMs = end.getTime() + ONE_DAY_MS - const startMs = endMs - days * ONE_DAY_MS + const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90) type Acc = { companyId: Id<"companies"> @@ -1671,22 +1755,24 @@ export async function hoursByClientHandler( } export const hoursByClient = query({ - args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()) }, + args: { + tenantId: v.string(), + viewerId: v.id("users"), + range: v.optional(v.string()), + dateFrom: v.optional(v.string()), + dateTo: v.optional(v.string()), + }, handler: hoursByClientHandler, }) // Internal variant used by scheduled jobs: skips viewer scoping and aggregates for the whole tenant export async function hoursByClientInternalHandler( ctx: QueryCtx, - { tenantId, range }: { tenantId: string; range?: string } + { tenantId, range, dateFrom, dateTo }: { tenantId: string; range?: string; dateFrom?: string; dateTo?: string } ) { const tickets = await fetchTickets(ctx, tenantId) - const days = range === "7d" ? 7 : range === "30d" ? 30 : 90 - const end = new Date() - end.setUTCHours(0, 0, 0, 0) - const endMs = end.getTime() + ONE_DAY_MS - const startMs = endMs - days * ONE_DAY_MS + const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90) type Acc = { companyId: Id<"companies"> @@ -1741,7 +1827,12 @@ export async function hoursByClientInternalHandler( } export const hoursByClientInternal = query({ - args: { tenantId: v.string(), range: v.optional(v.string()) }, + args: { + tenantId: v.string(), + range: v.optional(v.string()), + dateFrom: v.optional(v.string()), + dateTo: v.optional(v.string()), + }, handler: hoursByClientInternalHandler, }) diff --git a/src/components/admin/reports/report-templates-manager.tsx b/src/components/admin/reports/report-templates-manager.tsx index 54cc21a..8625596 100644 --- a/src/components/admin/reports/report-templates-manager.tsx +++ b/src/components/admin/reports/report-templates-manager.tsx @@ -303,7 +303,7 @@ export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplat onValueChange={(value) => setFilterCompanyId(value ?? "all")} options={companyOptions} placeholder="Todas as empresas" - className="h-9 w-full min-w-56 rounded-full border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800 md:w-64" + triggerClassName="h-9 w-full min-w-56 rounded-full border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800 md:w-64" /> + + + + + + ) +} + diff --git a/src/components/reports/backlog-report.tsx b/src/components/reports/backlog-report.tsx index ddf798d..422d47a 100644 --- a/src/components/reports/backlog-report.tsx +++ b/src/components/reports/backlog-report.tsx @@ -44,15 +44,26 @@ const queueBacklogChartConfig = { } export function BacklogReport() { - const [timeRange, setTimeRange] = useState("90d") + const [timeRange, setTimeRange] = useState<"90d" | "30d" | "7d">("90d") const [companyId, setCompanyId] = usePersistentCompanyFilter("all") const [schedulerOpen, setSchedulerOpen] = useState(false) + const [dateFrom, setDateFrom] = useState(null) + const [dateTo, setDateTo] = useState(null) const { session, convexUserId, isStaff, isAdmin } = useAuth() const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID const enabled = Boolean(isStaff && convexUserId) const data = useQuery( api.reports.backlogOverview, - enabled ? { tenantId, viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) } : "skip" + enabled + ? { + tenantId, + viewerId: convexUserId as Id<"users">, + range: timeRange, + companyId: companyId === "all" ? undefined : (companyId as Id<"companies">), + dateFrom, + dateTo, + } + : "skip" ) const companies = useQuery( api.companies.list, @@ -106,8 +117,18 @@ export function BacklogReport() { companyId={companyId} onCompanyChange={(value) => setCompanyId(value)} companyOptions={companyOptions} - timeRange={timeRange as "90d" | "30d" | "7d"} - onTimeRangeChange={(value) => setTimeRange(value)} + timeRange={timeRange} + onTimeRangeChange={(value) => { + setTimeRange(value as "90d" | "30d" | "7d") + setDateFrom(null) + setDateTo(null) + }} + dateFrom={dateFrom} + dateTo={dateTo} + onDateRangeChange={({ from, to }) => { + setDateFrom(from) + setDateTo(to) + }} exportHref={`/api/reports/backlog.xlsx?range=${timeRange}${ companyId !== "all" ? `&companyId=${companyId}` : "" }`} diff --git a/src/components/reports/category-report.tsx b/src/components/reports/category-report.tsx index 1a8a955..2508c60 100644 --- a/src/components/reports/category-report.tsx +++ b/src/components/reports/category-report.tsx @@ -53,9 +53,11 @@ const chartConfig = { export function CategoryReport() { const { session, convexUserId, isStaff, isAdmin } = useAuth() const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID - const [timeRange, setTimeRange] = useState("90d") + const [timeRange, setTimeRange] = useState<"90d" | "30d" | "7d">("90d") const [companyId, setCompanyId] = usePersistentCompanyFilter("all") const [schedulerOpen, setSchedulerOpen] = useState(false) + const [dateFrom, setDateFrom] = useState(null) + const [dateTo, setDateTo] = useState(null) const enabled = Boolean(isStaff && convexUserId) const companyFilter = companyId !== "all" ? (companyId as Id<"companies">) : undefined @@ -68,6 +70,8 @@ export function CategoryReport() { viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyFilter, + dateFrom, + dateTo, } : "skip", ) as CategoryInsightsResponse | undefined @@ -181,8 +185,18 @@ export function CategoryReport() { companyId={companyId} onCompanyChange={(value) => setCompanyId(value)} companyOptions={companyOptions} - timeRange={timeRange as "90d" | "30d" | "7d"} - onTimeRangeChange={(value) => setTimeRange(value)} + timeRange={timeRange} + onTimeRangeChange={(value) => { + setTimeRange(value as "90d" | "30d" | "7d") + setDateFrom(null) + setDateTo(null) + }} + dateFrom={dateFrom} + dateTo={dateTo} + onDateRangeChange={({ from, to }) => { + setDateFrom(from) + setDateTo(to) + }} onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined} /> diff --git a/src/components/reports/csat-report.tsx b/src/components/reports/csat-report.tsx index cc08efe..b2009a2 100644 --- a/src/components/reports/csat-report.tsx +++ b/src/components/reports/csat-report.tsx @@ -26,13 +26,22 @@ export function CsatReport() { const [companyId, setCompanyId] = usePersistentCompanyFilter("all") const [timeRange, setTimeRange] = useState("90d") const [schedulerOpen, setSchedulerOpen] = useState(false) + const [dateFrom, setDateFrom] = useState(null) + const [dateTo, setDateTo] = useState(null) const { session, convexUserId, isStaff, isAdmin } = useAuth() const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID const enabled = Boolean(isStaff && convexUserId) const data = useQuery( api.reports.csatOverview, enabled - ? ({ 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: companyId === "all" ? undefined : (companyId as Id<"companies">), + dateFrom, + dateTo, + }) : "skip" ) const companies = useQuery( @@ -94,7 +103,17 @@ export function CsatReport() { onCompanyChange={(value) => handleCompanyChange(value)} companyOptions={comboboxOptions} timeRange={timeRange as "90d" | "30d" | "7d"} - onTimeRangeChange={(value) => setTimeRange(value)} + onTimeRangeChange={(value) => { + setTimeRange(value) + setDateFrom(null) + setDateTo(null) + }} + dateFrom={dateFrom} + dateTo={dateTo} + onDateRangeChange={({ from, to }) => { + setDateFrom(from) + setDateTo(to) + }} exportHref={`/api/reports/csat.xlsx?range=${timeRange}${ companyId !== "all" ? `&companyId=${companyId}` : "" }`} diff --git a/src/components/reports/hours-report.tsx b/src/components/reports/hours-report.tsx index 38f358f..b64eb5d 100644 --- a/src/components/reports/hours-report.tsx +++ b/src/components/reports/hours-report.tsx @@ -41,6 +41,21 @@ type HoursItem = { contractedHoursPerMonth?: number | null } +type MachineHoursItem = { + machineId: string + machineHostname: string | null + companyId: string | null + companyName: string | null + internalMs: number + externalMs: number + totalMs: number +} + +type HoursByMachineResponse = { + rangeDays: number + items: MachineHoursItem[] +} + const topClientsChartConfig = { internas: { label: "Horas internas", @@ -53,19 +68,44 @@ const topClientsChartConfig = { } satisfies ChartConfig export function HoursReport() { - const [timeRange, setTimeRange] = useState("90d") + const [timeRange, setTimeRange] = useState<"90d" | "30d" | "7d" | "365d" | "all">("90d") const [companyId, setCompanyId] = usePersistentCompanyFilter("all") const [billingFilter, setBillingFilter] = useState<"all" | "avulso" | "contratado">("all") const [schedulerOpen, setSchedulerOpen] = useState(false) + const [groupBy, setGroupBy] = useState<"company" | "machine">("company") + const [dateFrom, setDateFrom] = useState(null) + const [dateTo, setDateTo] = useState(null) const { session, convexUserId, isStaff, isAdmin } = useAuth() const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID const enabled = Boolean(isStaff && convexUserId) const data = useQuery( api.reports.hoursByClient, - enabled ? { tenantId, viewerId: convexUserId as Id<"users">, range: timeRange } : "skip" + enabled && groupBy === "company" + ? { + tenantId, + viewerId: convexUserId as Id<"users">, + range: timeRange === "365d" || timeRange === "all" ? "90d" : timeRange, + dateFrom, + dateTo, + } + : "skip" ) as { rangeDays: number; items: HoursItem[] } | undefined + const machinesData = useQuery( + api.reports.hoursByMachine, + enabled && groupBy === "machine" + ? { + tenantId, + viewerId: convexUserId as Id<"users">, + range: timeRange, + companyId: companyId === "all" ? undefined : (companyId as Id<"companies">), + dateFrom, + dateTo, + } + : "skip" + ) as HoursByMachineResponse | undefined + const companies = useQuery( api.companies.list, enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip" @@ -84,7 +124,7 @@ export function HoursReport() { })), ] }, [companies]) - const filtered = useMemo(() => { + const filteredCompanies = useMemo(() => { let items = data?.items ?? [] if (companyId !== "all") { items = items.filter((it) => String(it.companyId) === companyId) @@ -97,8 +137,24 @@ export function HoursReport() { return items }, [data?.items, companyId, billingFilter]) + const filteredMachines = useMemo(() => { + let items = machinesData?.items ?? [] + if (companyId !== "all") { + items = items.filter((it) => String(it.companyId) === companyId) + } + if (billingFilter === "avulso") { + // Para futuro: quando tivermos flag de avulso por máquina; por enquanto, não filtra + return items + } + if (billingFilter === "contratado") { + return items + } + return items + }, [machinesData?.items, companyId, billingFilter]) + const totals = useMemo(() => { - return filtered.reduce( + const source = groupBy === "machine" ? filteredMachines : filteredCompanies + return source.reduce( (acc, item) => { acc.internal += item.internalMs / 3600000 acc.external += item.externalMs / 3600000 @@ -107,13 +163,13 @@ export function HoursReport() { }, { internal: 0, external: 0, total: 0 } ) - }, [filtered]) + }, [filteredCompanies, filteredMachines, groupBy]) // No number formatter needed; we use formatHoursCompact for hours - const filteredWithComputed = useMemo( + const filteredCompaniesWithComputed = useMemo( () => - filtered.map((row) => { + filteredCompanies.map((row) => { const internal = row.internalMs / 3600000 const external = row.externalMs / 3600000 const total = row.totalMs / 3600000 @@ -129,11 +185,11 @@ export function HoursReport() { usagePercent, } }), - [filtered] + [filteredCompanies] ) const topClientsData = useMemo( () => - filteredWithComputed + filteredCompaniesWithComputed .slice() .sort((a, b) => b.total - a.total) .slice(0, 10) @@ -142,7 +198,7 @@ export function HoursReport() { internas: row.internal, externas: row.external, })), - [filteredWithComputed] + [filteredCompaniesWithComputed] ) return ( @@ -161,68 +217,184 @@ export function HoursReport() { companyId={companyId} onCompanyChange={(value) => setCompanyId(value)} companyOptions={companyOptions} - timeRange={timeRange as "90d" | "30d" | "7d"} - onTimeRangeChange={(value) => setTimeRange(value)} + timeRange={timeRange} + onTimeRangeChange={(value) => { + setTimeRange(value) + setDateFrom(null) + setDateTo(null) + }} showBillingFilter billingFilter={billingFilter} onBillingFilterChange={(value) => setBillingFilter(value)} - exportHref={`/api/reports/hours-by-client.xlsx?range=${timeRange}${ - companyId !== "all" ? `&companyId=${companyId}` : "" - }`} + exportHref={`/api/reports/hours-by-client.xlsx?range=${ + timeRange === "365d" || timeRange === "all" ? "90d" : timeRange + }${companyId !== "all" ? `&companyId=${companyId}` : ""}`} onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined} + dateFrom={dateFrom} + dateTo={dateTo} + onDateRangeChange={({ from, to }) => { + setDateFrom(from) + setDateTo(to) + }} + allowExtendedRanges={groupBy === "machine"} + extraFilters={ +
+ Agrupar por +
+ + +
+
+ } /> - Top clientes por horas - Comparativo empilhado de horas internas x externas (top 10). + + {groupBy === "company" ? "Top clientes por horas" : "Top máquinas por horas"} + + + {groupBy === "company" + ? "Comparativo empilhado de horas internas x externas (top 10 empresas)." + : "Comparativo empilhado de horas internas x externas (top 10 máquinas)."} + - {!filteredWithComputed || filteredWithComputed.length === 0 ? ( -

Sem dados para o período.

+ {groupBy === "company" ? ( + !filteredCompaniesWithComputed || filteredCompaniesWithComputed.length === 0 ? ( +

+ Sem dados para o período. +

+ ) : ( + + + + formatHoursCompact(Number(value))} + /> + + ( + {String(value)} + )} + formatter={(value, name) => ( + <> + + {name === "internas" ? "Horas internas" : "Horas externas"} + + + {formatHoursCompact(Number(value))} + + + )} + /> + } + /> + } /> + + + + + ) ) : ( - - - - formatHoursCompact(Number(value))} - /> - - {String(value)}} - formatter={(value, name) => ( - <> - - {name === "internas" ? "Horas internas" : "Horas externas"} - - {formatHoursCompact(Number(value))} - - )} + <> + {!filteredMachines || filteredMachines.length === 0 ? ( +

+ Nenhuma máquina encontrada para o filtro selecionado. +

+ ) : ( + + b.totalMs - a.totalMs) + .slice(0, 10) + .map((row) => ({ + name: row.machineHostname ?? row.machineId, + internas: row.internalMs / 3600000, + externas: row.externalMs / 3600000, + }))} + layout="vertical" + margin={{ top: 16, right: 24, bottom: 16, left: 0 }} + barCategoryGap={12} + > + + formatHoursCompact(Number(value))} /> - } - /> - } /> - - - - + + ( + {String(value)} + )} + formatter={(value, name) => ( + <> + + {name === "internas" ? "Horas internas" : "Horas externas"} + + + {formatHoursCompact(Number(value))} + + + )} + /> + } + /> + } /> + + +
+
+ )} + )}
+ Horas - Visualize o esforço interno e externo por empresa e acompanhe o consumo contratado. + + {groupBy === "company" + ? "Visualize o esforço interno e externo por empresa e acompanhe o consumo contratado." + : "Visualize o esforço interno e externo por máquina dentro dos filtros selecionados."} +
@@ -238,65 +410,125 @@ export function HoursReport() { ))}
- {filteredWithComputed.length === 0 ? ( + {groupBy === "company" ? ( + !filteredCompaniesWithComputed.length ? ( +
+ Nenhuma empresa encontrada para o filtro selecionado. +
+ ) : ( +
+ {filteredCompaniesWithComputed.map((row) => ( +
+
+
+

{row.name}

+

ID {row.companyId}

+
+ + {row.isAvulso ? "Cliente avulso" : "Recorrente"} + +
+
+
+ Horas internas + + {formatHoursCompact(row.internal)} + +
+
+ Horas externas + + {formatHoursCompact(row.external)} + +
+
+ Total + + {formatHoursCompact(row.total)} + +
+
+
+
+ Contratadas/mês + + {row.contracted ? formatHoursCompact(row.contracted) : "—"} + +
+
+
+ Uso + + {row.usagePercent !== null ? `${row.usagePercent}%` : "—"} + +
+ {row.usagePercent !== null ? ( + + ) : ( +
+ Defina horas contratadas para acompanhar o uso +
+ )} +
+
+
+ ))} +
+ ) + ) : !filteredMachines.length ? (
- Nenhuma empresa encontrada para o filtro selecionado. + Nenhuma máquina encontrada para o filtro selecionado.
) : (
- {filteredWithComputed.map((row) => ( -
-
-
-

{row.name}

-

ID {row.companyId}

+ {filteredMachines.map((row) => { + const internal = row.internalMs / 3_600_000 + const external = row.externalMs / 3_600_000 + const total = row.totalMs / 3_600_000 + return ( +
+
+
+

+ {row.machineHostname ?? row.machineId} +

+

+ {row.companyName ?? "Sem empresa"} +

+
- - {row.isAvulso ? "Cliente avulso" : "Recorrente"} - -
-
-
- Horas internas - {formatHoursCompact(row.internal)} -
-
- Horas externas - {formatHoursCompact(row.external)} -
-
- Total - {formatHoursCompact(row.total)} -
-
-
-
- Contratadas/mês - - {row.contracted ? formatHoursCompact(row.contracted) : "—"} - -
-
-
- Uso - - {row.usagePercent !== null ? `${row.usagePercent}%` : "—"} +
+
+ Horas internas + + {formatHoursCompact(internal)} + +
+
+ Horas externas + + {formatHoursCompact(external)} + +
+
+ Total + + {formatHoursCompact(total)}
- {row.usagePercent !== null ? ( - - ) : ( -
- Defina horas contratadas para acompanhar o uso -
- )}
-
- ))} + ) + })}
)} diff --git a/src/components/reports/machine-category-report.tsx b/src/components/reports/machine-category-report.tsx index 387f55e..65d291d 100644 --- a/src/components/reports/machine-category-report.tsx +++ b/src/components/reports/machine-category-report.tsx @@ -48,8 +48,10 @@ type MachineHoursResponse = { } export function MachineCategoryReport() { - const [timeRange, setTimeRange] = useState("30d") + const [timeRange, setTimeRange] = useState<"90d" | "30d" | "7d" | "365d" | "all">("30d") const [companyId, setCompanyId] = usePersistentCompanyFilter("all") + const [dateFrom, setDateFrom] = useState(null) + const [dateTo, setDateTo] = useState(null) const { session, convexUserId, isStaff } = useAuth() const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID @@ -64,6 +66,8 @@ export function MachineCategoryReport() { viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">), + dateFrom, + dateTo, } as const) : "skip" ) as MachineCategoryReportData | undefined @@ -159,6 +163,8 @@ export function MachineCategoryReport() { companyId: companyId === "all" ? undefined : (companyId as Id<"companies">), machineId: selectedMachineId !== "all" ? (selectedMachineId as Id<"machines">) : undefined, userId: selectedUserId !== "all" ? (selectedUserId as Id<"users">) : undefined, + dateFrom, + dateTo, } as const) : "skip" ) as MachineHoursResponse | undefined @@ -236,8 +242,19 @@ export function MachineCategoryReport() { companyId={companyId} onCompanyChange={(value) => setCompanyId(value)} companyOptions={companyOptions} - timeRange={timeRange as "90d" | "30d" | "7d"} - onTimeRangeChange={(value) => setTimeRange(value)} + timeRange={timeRange} + onTimeRangeChange={(value) => { + setTimeRange(value) + setDateFrom(null) + setDateTo(null) + }} + dateFrom={dateFrom} + dateTo={dateTo} + onDateRangeChange={({ from, to }) => { + setDateFrom(from) + setDateTo(to) + }} + allowExtendedRanges /> @@ -260,7 +277,7 @@ export function MachineCategoryReport() { onValueChange={(value) => setSelectedMachineId(value ?? "all")} options={machineOptions} placeholder="Todas as máquinas" - className="h-10 w-full rounded-2xl border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800" + triggerClassName="h-10 w-full rounded-2xl border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800" />
@@ -272,7 +289,7 @@ export function MachineCategoryReport() { onValueChange={(value) => setSelectedUserId(value ?? "all")} options={userOptions} placeholder="Todos os usuários" - className="h-10 w-full rounded-2xl border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800" + triggerClassName="h-10 w-full rounded-2xl border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800" />
diff --git a/src/components/reports/report-filter-toolbar.tsx b/src/components/reports/report-filter-toolbar.tsx index 5eafc18..9668ced 100644 --- a/src/components/reports/report-filter-toolbar.tsx +++ b/src/components/reports/report-filter-toolbar.tsx @@ -6,9 +6,10 @@ import { Button } from "@/components/ui/button" import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { cn } from "@/lib/utils" +import { DateRangeButton } from "@/components/date-range-button" type BillingFilter = "all" | "avulso" | "contratado" -type TimeRange = "90d" | "30d" | "7d" +type TimeRange = "90d" | "30d" | "7d" | "365d" | "all" type ReportsFilterToolbarProps = { companyId: string @@ -25,6 +26,10 @@ type ReportsFilterToolbarProps = { onExportClick?: () => void isExporting?: boolean onOpenScheduler?: () => void + allowExtendedRanges?: boolean + dateFrom?: string | null + dateTo?: string | null + onDateRangeChange?: (next: { from: string | null; to: string | null }) => void } const BILLING_TOGGLE_ITEM = @@ -45,6 +50,10 @@ export function ReportsFilterToolbar({ onExportClick, isExporting = false, onOpenScheduler, + allowExtendedRanges = false, + dateFrom, + dateTo, + onDateRangeChange, }: ReportsFilterToolbarProps) { return (
@@ -54,8 +63,16 @@ export function ReportsFilterToolbar({ onValueChange={(next) => onCompanyChange(next ?? "all")} options={companyOptions} placeholder="Todas as empresas" - className="h-10 w-full min-w-56 rounded-2xl border border-border/60 bg-background/95 px-3 text-left text-sm font-semibold text-neutral-800 md:w-64" + triggerClassName="h-10 w-full min-w-56 rounded-2xl border border-border/60 bg-background/95 px-3 text-left text-sm font-semibold text-neutral-800 md:w-64" /> + {onDateRangeChange ? ( + + ) : null} {showBillingFilter ? ( 7 dias + {allowExtendedRanges ? ( + <> + + 12 meses + + + Todo histórico + + + ) : null}
{onOpenScheduler ? ( diff --git a/src/components/reports/sla-report.tsx b/src/components/reports/sla-report.tsx index 83325fb..6f0a7a7 100644 --- a/src/components/reports/sla-report.tsx +++ b/src/components/reports/sla-report.tsx @@ -63,13 +63,22 @@ export function SlaReport() { const [companyId, setCompanyId] = usePersistentCompanyFilter("all") const [timeRange, setTimeRange] = useState<"90d" | "30d" | "7d">("90d") const [schedulerOpen, setSchedulerOpen] = useState(false) + const [dateFrom, setDateFrom] = useState(null) + const [dateTo, setDateTo] = useState(null) const { session, convexUserId, isStaff, isAdmin } = useAuth() const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID const enabled = Boolean(isStaff && convexUserId) const data = useQuery( api.reports.slaOverview, enabled - ? ({ 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: companyId === "all" ? undefined : (companyId as Id<"companies">), + dateFrom, + dateTo, + }) : "skip" ) const agents = useQuery( @@ -82,7 +91,14 @@ export function SlaReport() { const openedResolved = useQuery( api.reports.openedResolvedByDay, enabled - ? ({ 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: companyId === "all" ? undefined : (companyId as Id<"companies">), + dateFrom, + dateTo, + }) : "skip" ) as { rangeDays: number; series: Array<{ date: string; opened: number; resolved: number }> } | undefined @@ -139,7 +155,17 @@ export function SlaReport() { onCompanyChange={(value) => setCompanyId(value)} companyOptions={companyOptions} timeRange={timeRange} - onTimeRangeChange={(value) => setTimeRange(value)} + onTimeRangeChange={(value) => { + setTimeRange(value as "90d" | "30d" | "7d") + setDateFrom(null) + setDateTo(null) + }} + dateFrom={dateFrom} + dateTo={dateTo} + onDateRangeChange={({ from, to }) => { + setDateFrom(from) + setDateTo(to) + }} exportHref={`/api/reports/sla.xlsx?range=${timeRange}${ companyId !== "all" ? `&companyId=${companyId}` : "" }`} diff --git a/src/components/tickets/tickets-filters.tsx b/src/components/tickets/tickets-filters.tsx index 5293ae0..d9f1a55 100644 --- a/src/components/tickets/tickets-filters.tsx +++ b/src/components/tickets/tickets-filters.tsx @@ -1,17 +1,7 @@ "use client" import { useEffect, useMemo, useState } from "react" -import { - IconCalendar, - IconFilter, - IconRefresh, - IconSearch, - IconList, - IconBuilding, - IconTags, - IconUser, -} from "@tabler/icons-react" -import type { DateRange } from "react-day-picker" +import { IconFilter, IconRefresh, IconSearch, IconList, IconBuilding, IconTags, IconUser } from "@tabler/icons-react" import { ticketPrioritySchema, type TicketStatus } from "@/lib/schemas/ticket" import { PriorityIcon } from "@/components/tickets/priority-select" @@ -24,7 +14,6 @@ export { defaultTicketFilters } import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { Calendar } from "@/components/ui/calendar" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Select, @@ -35,6 +24,7 @@ import { } from "@/components/ui/select" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox" +import { DateRangeButton } from "@/components/date-range-button" type QueueOption = string @@ -72,93 +62,6 @@ const priorityOptions = ticketPrioritySchema.options.map((priority) => ({ }[priority], })) -function strToDate(value?: string | null): Date | undefined { - if (!value) return undefined - const [y, m, d] = value.split("-").map(Number) - if (!y || !m || !d) return undefined - return new Date(y, m - 1, d) -} - -function dateToStr(value?: Date): string | null { - if (!value) return null - const y = value.getFullYear() - const m = String(value.getMonth() + 1).padStart(2, "0") - const d = String(value.getDate()).padStart(2, "0") - return `${y}-${m}-${d}` -} - -function formatPtBR(value?: Date): string { - return value ? value.toLocaleDateString("pt-BR") : "" -} - -type DateRangeButtonProps = { - from: string | null - to: string | null - onChange: (next: { from: string | null; to: string | null }) => void - className?: string -} - -function DateRangeButton({ from, to, onChange, className }: DateRangeButtonProps) { - const range: DateRange | undefined = useMemo( - () => ({ - from: strToDate(from), - to: strToDate(to), - }), - [from, to] - ) - - const label = - range?.from && range?.to - ? `${formatPtBR(range.from)} - ${formatPtBR(range.to)}` - : "Período" - - const handleSelect = (next?: DateRange) => { - if (!next?.from && !next?.to) { - onChange({ from: null, to: null }) - return - } - - if (next?.from && !next?.to) { - const single = dateToStr(next.from) - if (from && to && from === to && single === from) { - onChange({ from: null, to: null }) - return - } - onChange({ from: single, to: single }) - return - } - - const nextFrom = dateToStr(next?.from) ?? null - const nextTo = dateToStr(next?.to) ?? nextFrom - onChange({ from: nextFrom, to: nextTo }) - } - - return ( - - - - - - - - - ) -} - export function TicketsFilters({ onChange, queues = [],