diff --git a/PROXIMOS_PASSOS.md b/PROXIMOS_PASSOS.md index a7f1653..392097a 100644 --- a/PROXIMOS_PASSOS.md +++ b/PROXIMOS_PASSOS.md @@ -32,8 +32,8 @@ Lista priorizada de evoluções propostas para o Sistema de Chamados. Confira `a - [ ] Exibir chamados em: atendimento, laboratório, visitas - [ ] Indicadores: abertos, resolvidos, tempo médio, SLA - [ ] Criar **relatório de horas por cliente (CSV/Dashboard)** - - [ ] Separar por atendimento interno e externo - - [ ] Filtrar por período (dia, semana, mês) + - [x] Separar por atendimento interno e externo + - [x] Filtrar por período (dia, semana, mês) - [x] Permitir exportar relatórios completos (CSV ou PDF) --- @@ -44,6 +44,7 @@ Lista priorizada de evoluções propostas para o Sistema de Chamados. Confira `a - [x] Adicionar botão **Play externo** (atendimento presencial) - [x] Separar contagem de horas por tipo (interno/externo) - [ ] Exibir e somar **horas gastas por cliente** (com base no tipo) + - [x] Relatório com totais (interno/externo/total) - [ ] Incluir no cadastro: - [ ] Horas contratadas por mês - [x] Tipo de cliente: mensalista ou avulso diff --git a/convex/reports.ts b/convex/reports.ts index 924830e..a221615 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -397,3 +397,70 @@ export const ticketsByChannel = query({ }; }, }); + +export const hoursByClient = query({ + args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()) }, + handler: async (ctx, { tenantId, viewerId, range }) => { + 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 + + // Accumulate by company + type Acc = { + companyId: string + name: string + isAvulso: boolean + internalMs: number + externalMs: number + totalMs: number + contractedHoursPerMonth?: number | null + } + const map = new Map() + + for (const t of tickets) { + // only consider tickets updated in range as a proxy for recent work + if (t.updatedAt < startMs || t.updatedAt >= endMs) continue + const companyId = (t as any).companyId ?? null + if (!companyId) continue + + let acc = map.get(companyId) + if (!acc) { + const company = await ctx.db.get(companyId) + acc = { + companyId, + name: (company as any)?.name ?? "Sem empresa", + isAvulso: Boolean((company as any)?.isAvulso ?? false), + internalMs: 0, + externalMs: 0, + totalMs: 0, + contractedHoursPerMonth: (company as any)?.contractedHoursPerMonth ?? null, + } + map.set(companyId, acc) + } + const internal = ((t as any).internalWorkedMs ?? 0) as number + const external = ((t as any).externalWorkedMs ?? 0) as number + acc.internalMs += internal + acc.externalMs += external + acc.totalMs += internal + external + } + + const items = Array.from(map.values()).sort((a, b) => b.totalMs - a.totalMs) + return { + rangeDays: days, + items: items.map((i) => ({ + companyId: i.companyId, + name: i.name, + isAvulso: i.isAvulso, + internalMs: i.internalMs, + externalMs: i.externalMs, + totalMs: i.totalMs, + contractedHoursPerMonth: i.contractedHoursPerMonth ?? null, + })), + } + }, +}) diff --git a/convex/schema.ts b/convex/schema.ts index 03adc56..520387c 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -21,6 +21,7 @@ export default defineSchema({ name: v.string(), slug: v.string(), isAvulso: v.optional(v.boolean()), + contractedHoursPerMonth: v.optional(v.number()), cnpj: v.optional(v.string()), domain: v.optional(v.string()), phone: v.optional(v.string()), diff --git a/src/app/api/admin/companies/[id]/route.ts b/src/app/api/admin/companies/[id]/route.ts index 73fb94b..57a2820 100644 --- a/src/app/api/admin/companies/[id]/route.ts +++ b/src/app/api/admin/companies/[id]/route.ts @@ -16,6 +16,10 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id if (key in body) updates[key] = body[key] ?? null } if ("isAvulso" in body) updates.isAvulso = Boolean(body.isAvulso) + if ("contractedHoursPerMonth" in body) { + const raw = body.contractedHoursPerMonth + updates.contractedHoursPerMonth = typeof raw === "number" ? raw : raw ? Number(raw) : null + } try { const company = await prisma.company.update({ where: { id }, data: updates as any }) diff --git a/src/app/api/admin/companies/route.ts b/src/app/api/admin/companies/route.ts index beeef63..b400d65 100644 --- a/src/app/api/admin/companies/route.ts +++ b/src/app/api/admin/companies/route.ts @@ -20,7 +20,7 @@ export async function POST(request: Request) { if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) const body = await request.json() - const { name, slug, isAvulso, cnpj, domain, phone, description, address } = body ?? {} + const { name, slug, isAvulso, cnpj, domain, phone, description, address, contractedHoursPerMonth } = body ?? {} if (!name || !slug) { return NextResponse.json({ error: "Nome e slug são obrigatórios" }, { status: 400 }) } @@ -32,6 +32,7 @@ export async function POST(request: Request) { name: String(name), slug: String(slug), isAvulso: Boolean(isAvulso ?? false), + contractedHoursPerMonth: typeof contractedHoursPerMonth === "number" ? contractedHoursPerMonth : contractedHoursPerMonth ? Number(contractedHoursPerMonth) : null, cnpj: cnpj ? String(cnpj) : null, domain: domain ? String(domain) : null, phone: phone ? String(phone) : null, diff --git a/src/app/api/reports/hours-by-client.csv/route.ts b/src/app/api/reports/hours-by-client.csv/route.ts new file mode 100644 index 0000000..0a9db89 --- /dev/null +++ b/src/app/api/reports/hours-by-client.csv/route.ts @@ -0,0 +1,83 @@ +import { NextResponse } from "next/server" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { env } from "@/lib/env" +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" + +export const runtime = "nodejs" + +function csvEscape(value: unknown): string { + const s = value == null ? "" : String(value) + if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"' + return s +} +function rowsToCsv(rows: Array>): string { + return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n" +} +function msToHours(ms: number) { + return (ms / 3600000).toFixed(2) +} + +export async function GET(request: Request) { + const session = await assertAuthenticatedSession() + if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + + const { searchParams } = new URL(request.url) + const range = searchParams.get("range") ?? undefined + + const client = new ConvexHttpClient(convexUrl) + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + let viewerId: string | null = null + try { + const ensuredUser = await client.mutation(api.users.ensureUser, { + tenantId, + name: session.user.name ?? session.user.email, + email: session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + viewerId = ensuredUser?._id ?? null + } catch (error) { + return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 }) + } + if (!viewerId) return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 }) + + try { + const report = await client.query(api.reports.hoursByClient, { + tenantId, + viewerId: viewerId as unknown as Id<"users">, + range, + }) + + const rows: Array> = [] + rows.push(["Relatório", "Horas por cliente"]) + rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? '90d')]) + rows.push([]) + rows.push(["Cliente", "Avulso", "Horas internas", "Horas externas", "Horas totais", "Horas contratadas/mês", "% uso"]) + for (const item of report.items) { + const internalH = msToHours(item.internalMs) + const externalH = msToHours(item.externalMs) + const totalH = msToHours(item.totalMs) + const contracted = item.contractedHoursPerMonth ?? "—" + const pct = item.contractedHoursPerMonth ? ((item.totalMs / 3600000) / item.contractedHoursPerMonth * 100).toFixed(1) + "%" : "—" + rows.push([item.name, item.isAvulso ? "Sim" : "Não", internalH, externalH, totalH, contracted, pct]) + } + const csv = rowsToCsv(rows) + return new NextResponse(csv, { + headers: { + "Content-Type": "text/csv; charset=UTF-8", + "Content-Disposition": `attachment; filename="hours-by-client-${tenantId}-${report.rangeDays ?? '90'}d.csv"`, + "Cache-Control": "no-store", + }, + }) + } catch (error) { + return NextResponse.json({ error: "Falha ao gerar CSV de horas por cliente" }, { status: 500 }) + } +} + diff --git a/src/app/reports/hours/page.tsx b/src/app/reports/hours/page.tsx new file mode 100644 index 0000000..4463488 --- /dev/null +++ b/src/app/reports/hours/page.tsx @@ -0,0 +1,23 @@ +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" +import { HoursReport } from "@/components/reports/hours-report" + +export const dynamic = "force-dynamic" + +export default function ReportsHoursPage() { + return ( + + } + > +
+ +
+
+ ) +} + diff --git a/src/components/admin/companies/admin-companies-manager.tsx b/src/components/admin/companies/admin-companies-manager.tsx index bcd077c..78ef647 100644 --- a/src/components/admin/companies/admin-companies-manager.tsx +++ b/src/components/admin/companies/admin-companies-manager.tsx @@ -23,6 +23,7 @@ type Company = { name: string slug: string isAvulso: boolean + contractedHoursPerMonth?: number | null cnpj: string | null domain: string | null phone: string | null @@ -145,6 +146,16 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: setForm((p) => ({ ...p, address: e.target.value }))} /> +
+ + setForm((p) => ({ ...p, contractedHoursPerMonth: e.target.value === "" ? undefined : Number(e.target.value) }))} + /> +
) } - diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index b604159..87ca969 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -76,6 +76,7 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = { { title: "SLA e produtividade", url: "/reports/sla", icon: Gauge, requiredRole: "staff" }, { title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" }, { title: "Backlog", url: "/reports/backlog", icon: BarChart3, requiredRole: "staff" }, + { title: "Horas por cliente", url: "/reports/hours", icon: Gauge, requiredRole: "staff" }, ], }, { diff --git a/src/components/reports/hours-report.tsx b/src/components/reports/hours-report.tsx new file mode 100644 index 0000000..7ff4560 --- /dev/null +++ b/src/components/reports/hours-report.tsx @@ -0,0 +1,99 @@ +"use client" + +import { useState, useMemo } from "react" +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 { Card, CardAction, 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" + +function formatHours(ms: number) { + const hours = ms / 3600000 + return hours.toFixed(2) +} + +export function HoursReport() { + const [timeRange, setTimeRange] = useState("90d") + const { session, convexUserId } = useAuth() + const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID + + const data = useQuery( + api.reports.hoursByClient, + convexUserId ? { tenantId, viewerId: convexUserId as Id<"users">, range: timeRange } : "skip" + ) as { rangeDays: number; items: Array<{ companyId: string; name: string; isAvulso: boolean; internalMs: number; externalMs: number; totalMs: number; contractedHoursPerMonth?: number | null }> } | undefined + + const items = data?.items ?? [] + + return ( +
+ + + Horas por cliente + Horas internas e externas registradas por empresa. + + + + 90 dias + 30 dias + 7 dias + + + + +
+ + + + + + + + + + + + + + {items.map((row) => { + const totalH = Number(formatHours(row.totalMs)) + const contracted = row.contractedHoursPerMonth ?? null + const pct = contracted ? Math.round((totalH / contracted) * 100) : null + const pctBadgeVariant = pct !== null && pct >= 90 ? "destructive" : "secondary" + return ( + + + + + + + + + + ) + })} + +
ClienteAvulsoHoras internasHoras externasTotalContratadas/mêsUso
{row.name}{row.isAvulso ? "Sim" : "Não"}{formatHours(row.internalMs)}{formatHours(row.externalMs)}{formatHours(row.totalMs)}{contracted ?? "—"} + {pct !== null ? ( + + {pct}% + + ) : ( + + )} +
+
+
+
+
+ ) +} +