feat: machine reports with filters and hours
This commit is contained in:
parent
6938bebdbb
commit
82875a2252
2 changed files with 403 additions and 11 deletions
|
|
@ -1335,7 +1335,16 @@ export async function ticketsByMachineAndCategoryHandler(
|
||||||
viewerId,
|
viewerId,
|
||||||
range,
|
range,
|
||||||
companyId,
|
companyId,
|
||||||
}: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
|
machineId,
|
||||||
|
userId,
|
||||||
|
}: {
|
||||||
|
tenantId: string
|
||||||
|
viewerId: Id<"users">
|
||||||
|
range?: string
|
||||||
|
companyId?: Id<"companies">
|
||||||
|
machineId?: Id<"machines">
|
||||||
|
userId?: Id<"users">
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
const viewer = await requireStaff(ctx, viewerId, tenantId)
|
const viewer = await requireStaff(ctx, viewerId, tenantId)
|
||||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90
|
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90
|
||||||
|
|
@ -1372,8 +1381,11 @@ export async function ticketsByMachineAndCategoryHandler(
|
||||||
const hasMachine = Boolean(ticket.machineId) || Boolean(ticket.machineSnapshot)
|
const hasMachine = Boolean(ticket.machineId) || Boolean(ticket.machineSnapshot)
|
||||||
if (!hasMachine) continue
|
if (!hasMachine) continue
|
||||||
|
|
||||||
|
if (machineId && ticket.machineId !== machineId) continue
|
||||||
|
if (userId && ticket.requesterId !== userId) continue
|
||||||
|
|
||||||
const date = formatDateKey(createdAt)
|
const date = formatDateKey(createdAt)
|
||||||
const machineId = (ticket.machineId ?? null) as Id<"machines"> | null
|
const machineIdValue = (ticket.machineId ?? null) as Id<"machines"> | null
|
||||||
const machineSnapshot = (ticket.machineSnapshot ?? null) as
|
const machineSnapshot = (ticket.machineSnapshot ?? null) as
|
||||||
| {
|
| {
|
||||||
hostname?: string | null
|
hostname?: string | null
|
||||||
|
|
@ -1414,19 +1426,19 @@ export async function ticketsByMachineAndCategoryHandler(
|
||||||
|
|
||||||
const key = [
|
const key = [
|
||||||
date,
|
date,
|
||||||
machineId ? String(machineId) : "null",
|
machineIdValue ? String(machineIdValue) : "null",
|
||||||
machineHostname ?? "",
|
machineHostname ?? "",
|
||||||
rawCategoryId ?? "uncategorized",
|
rawCategoryId ?? "uncategorized",
|
||||||
companyIdValue ? String(companyIdValue) : "null",
|
companyIdValue ? String(companyIdValue) : "null",
|
||||||
].join("|")
|
].join("|")
|
||||||
|
|
||||||
const existing = aggregated.get(key)
|
const existing = aggregated.get(key)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.total += 1
|
existing.total += 1
|
||||||
} else {
|
} else {
|
||||||
aggregated.set(key, {
|
aggregated.set(key, {
|
||||||
date,
|
date,
|
||||||
machineId,
|
machineId: machineIdValue,
|
||||||
machineHostname,
|
machineHostname,
|
||||||
companyId: companyIdValue,
|
companyId: companyIdValue,
|
||||||
companyName,
|
companyName,
|
||||||
|
|
@ -1457,10 +1469,142 @@ export const ticketsByMachineAndCategory = query({
|
||||||
viewerId: v.id("users"),
|
viewerId: v.id("users"),
|
||||||
range: v.optional(v.string()),
|
range: v.optional(v.string()),
|
||||||
companyId: v.optional(v.id("companies")),
|
companyId: v.optional(v.id("companies")),
|
||||||
|
machineId: v.optional(v.id("machines")),
|
||||||
|
userId: v.optional(v.id("users")),
|
||||||
},
|
},
|
||||||
handler: ticketsByMachineAndCategoryHandler,
|
handler: ticketsByMachineAndCategoryHandler,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
type MachineHoursEntry = {
|
||||||
|
machineId: Id<"machines">
|
||||||
|
machineHostname: string | null
|
||||||
|
companyId: Id<"companies"> | null
|
||||||
|
companyName: string | null
|
||||||
|
internalMs: number
|
||||||
|
externalMs: number
|
||||||
|
totalMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hoursByMachineHandler(
|
||||||
|
ctx: QueryCtx,
|
||||||
|
{
|
||||||
|
tenantId,
|
||||||
|
viewerId,
|
||||||
|
range,
|
||||||
|
companyId,
|
||||||
|
machineId,
|
||||||
|
userId,
|
||||||
|
}: {
|
||||||
|
tenantId: string
|
||||||
|
viewerId: Id<"users">
|
||||||
|
range?: string
|
||||||
|
companyId?: Id<"companies">
|
||||||
|
machineId?: Id<"machines">
|
||||||
|
userId?: Id<"users">
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
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 machinesById = new Map<string, Doc<"machines"> | null>()
|
||||||
|
const companiesById = new Map<string, Doc<"companies"> | null>()
|
||||||
|
|
||||||
|
const map = new Map<string, MachineHoursEntry>()
|
||||||
|
|
||||||
|
for (const t of tickets) {
|
||||||
|
if (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
|
||||||
|
|
||||||
|
const machineIdValue = (t.machineId ?? null) as Id<"machines"> | null
|
||||||
|
if (!machineIdValue) continue
|
||||||
|
|
||||||
|
const key = String(machineIdValue)
|
||||||
|
|
||||||
|
let acc = map.get(key)
|
||||||
|
if (!acc) {
|
||||||
|
let machineDoc = machinesById.get(key)
|
||||||
|
if (machineDoc === undefined) {
|
||||||
|
machineDoc = (await ctx.db.get(machineIdValue)) as Doc<"machines"> | null
|
||||||
|
machinesById.set(key, machineDoc ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = (t.machineSnapshot ?? null) as { hostname?: string | null } | null
|
||||||
|
const machineHostname =
|
||||||
|
typeof machineDoc?.hostname === "string" && machineDoc.hostname.trim().length > 0
|
||||||
|
? machineDoc.hostname.trim()
|
||||||
|
: snapshot?.hostname && snapshot.hostname.trim().length > 0
|
||||||
|
? snapshot.hostname.trim()
|
||||||
|
: null
|
||||||
|
|
||||||
|
const companyIdValue = (t.companyId ??
|
||||||
|
(machineDoc?.companyId as Id<"companies"> | undefined) ??
|
||||||
|
null) as Id<"companies"> | null
|
||||||
|
|
||||||
|
let companyName: string | null = null
|
||||||
|
if (companyIdValue) {
|
||||||
|
const companyKey = String(companyIdValue)
|
||||||
|
let companyDoc = companiesById.get(companyKey)
|
||||||
|
if (companyDoc === undefined) {
|
||||||
|
companyDoc = (await ctx.db.get(companyIdValue)) as Doc<"companies"> | null
|
||||||
|
companiesById.set(companyKey, companyDoc ?? null)
|
||||||
|
}
|
||||||
|
if (companyDoc?.name && companyDoc.name.trim().length > 0) {
|
||||||
|
companyName = companyDoc.name.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
acc = {
|
||||||
|
machineId: machineIdValue,
|
||||||
|
machineHostname,
|
||||||
|
companyId: companyIdValue,
|
||||||
|
companyName,
|
||||||
|
internalMs: 0,
|
||||||
|
externalMs: 0,
|
||||||
|
totalMs: 0,
|
||||||
|
}
|
||||||
|
map.set(key, acc)
|
||||||
|
}
|
||||||
|
|
||||||
|
const internal = (t.internalWorkedMs ?? 0) as number
|
||||||
|
const external = (t.externalWorkedMs ?? 0) as number
|
||||||
|
acc.internalMs += internal
|
||||||
|
acc.externalMs += external
|
||||||
|
acc.totalMs += internal + external
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = Array.from(map.values()).sort((a, b) => {
|
||||||
|
if (b.totalMs !== a.totalMs) return b.totalMs - a.totalMs
|
||||||
|
const hostA = (a.machineHostname ?? "").toLowerCase()
|
||||||
|
const hostB = (b.machineHostname ?? "").toLowerCase()
|
||||||
|
return hostA.localeCompare(hostB)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
rangeDays: days,
|
||||||
|
items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hoursByMachine = query({
|
||||||
|
args: {
|
||||||
|
tenantId: v.string(),
|
||||||
|
viewerId: v.id("users"),
|
||||||
|
range: v.optional(v.string()),
|
||||||
|
companyId: v.optional(v.id("companies")),
|
||||||
|
machineId: v.optional(v.id("machines")),
|
||||||
|
userId: v.optional(v.id("users")),
|
||||||
|
},
|
||||||
|
handler: hoursByMachineHandler,
|
||||||
|
})
|
||||||
|
|
||||||
export async function hoursByClientHandler(
|
export async function hoursByClientHandler(
|
||||||
ctx: QueryCtx,
|
ctx: QueryCtx,
|
||||||
{ tenantId, viewerId, range }: { tenantId: string; viewerId: Id<"users">; range?: string }
|
{ tenantId, viewerId, range }: { tenantId: string; viewerId: Id<"users">; range?: string }
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||||
import { ReportsFilterToolbar } from "@/components/reports/report-filter-toolbar"
|
import { ReportsFilterToolbar } from "@/components/reports/report-filter-toolbar"
|
||||||
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||||
import { formatDateDM } from "@/lib/utils"
|
import { formatDateDM, formatHoursCompact } from "@/lib/utils"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
type MachineCategoryDailyItem = {
|
type MachineCategoryDailyItem = {
|
||||||
|
|
@ -32,6 +32,21 @@ type MachineCategoryReportData = {
|
||||||
items: MachineCategoryDailyItem[]
|
items: MachineCategoryDailyItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MachineHoursItem = {
|
||||||
|
machineId: string
|
||||||
|
machineHostname: string | null
|
||||||
|
companyId: string | null
|
||||||
|
companyName: string | null
|
||||||
|
internalMs: number
|
||||||
|
externalMs: number
|
||||||
|
totalMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type MachineHoursResponse = {
|
||||||
|
rangeDays: number
|
||||||
|
items: MachineHoursItem[]
|
||||||
|
}
|
||||||
|
|
||||||
export function MachineCategoryReport() {
|
export function MachineCategoryReport() {
|
||||||
const [timeRange, setTimeRange] = useState("30d")
|
const [timeRange, setTimeRange] = useState("30d")
|
||||||
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
||||||
|
|
@ -75,6 +90,79 @@ export function MachineCategoryReport() {
|
||||||
|
|
||||||
const items = useMemo(() => data?.items ?? [], [data])
|
const items = useMemo(() => data?.items ?? [], [data])
|
||||||
|
|
||||||
|
const machinesRaw = useQuery(
|
||||||
|
api.devices.listByTenant,
|
||||||
|
enabled
|
||||||
|
? ({
|
||||||
|
tenantId,
|
||||||
|
includeMetadata: false,
|
||||||
|
} as const)
|
||||||
|
: "skip"
|
||||||
|
) as Array<{ id: string; hostname?: string | null; companyId?: string | null; displayName?: string | null }> | undefined
|
||||||
|
|
||||||
|
const users = useQuery(
|
||||||
|
api.users.listCustomers,
|
||||||
|
enabled
|
||||||
|
? ({
|
||||||
|
tenantId,
|
||||||
|
viewerId: convexUserId as Id<"users">,
|
||||||
|
} as const)
|
||||||
|
: "skip"
|
||||||
|
) as Array<{ id: Id<"users">; name: string | null; email: string; companyId?: Id<"companies"> | null }> | undefined
|
||||||
|
|
||||||
|
const machineOptions = useMemo<SearchableComboboxOption[]>(() => {
|
||||||
|
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as máquinas" }]
|
||||||
|
if (!machinesRaw || machinesRaw.length === 0) return base
|
||||||
|
const filtered = machinesRaw.filter((machine) => {
|
||||||
|
if (!machine) return false
|
||||||
|
if (companyId === "all") return true
|
||||||
|
return String(machine.companyId ?? "") === String(companyId)
|
||||||
|
})
|
||||||
|
const mapped = filtered
|
||||||
|
.map((machine) => {
|
||||||
|
const id = String((machine as { id?: string; _id?: string }).id ?? (machine as { _id?: string })._id ?? "")
|
||||||
|
const hostname =
|
||||||
|
(machine.hostname ?? machine.displayName ?? "")?.toString().trim() || `Máquina ${id.slice(0, 8)}`
|
||||||
|
return { value: id, label: hostname }
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label, "pt-BR"))
|
||||||
|
return [base[0], ...mapped]
|
||||||
|
}, [machinesRaw, companyId])
|
||||||
|
|
||||||
|
const userOptions = useMemo<SearchableComboboxOption[]>(() => {
|
||||||
|
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todos os usuários" }]
|
||||||
|
if (!users || users.length === 0) return base
|
||||||
|
const filtered = users.filter((user) => {
|
||||||
|
if (!user) return false
|
||||||
|
if (companyId === "all") return true
|
||||||
|
return String(user.companyId ?? "") === String(companyId)
|
||||||
|
})
|
||||||
|
const mapped = filtered
|
||||||
|
.map((user) => {
|
||||||
|
const label = (user.name ?? user.email).trim()
|
||||||
|
return { value: String(user.id), label }
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label, "pt-BR"))
|
||||||
|
return [base[0], ...mapped]
|
||||||
|
}, [users, companyId])
|
||||||
|
|
||||||
|
const [selectedMachineId, setSelectedMachineId] = useState<string>("all")
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<string>("all")
|
||||||
|
|
||||||
|
const hours = useQuery(
|
||||||
|
api.reports.hoursByMachine,
|
||||||
|
enabled && selectedMachineId !== "all"
|
||||||
|
? ({
|
||||||
|
tenantId,
|
||||||
|
viewerId: convexUserId as Id<"users">,
|
||||||
|
range: timeRange,
|
||||||
|
companyId: companyId === "all" ? undefined : (companyId as Id<"companies">),
|
||||||
|
machineId: selectedMachineId !== "all" ? (selectedMachineId as Id<"machines">) : undefined,
|
||||||
|
userId: selectedUserId !== "all" ? (selectedUserId as Id<"users">) : undefined,
|
||||||
|
} as const)
|
||||||
|
: "skip"
|
||||||
|
) as MachineHoursResponse | undefined
|
||||||
|
|
||||||
const totals = useMemo(
|
const totals = useMemo(
|
||||||
() =>
|
() =>
|
||||||
items.reduce(
|
items.reduce(
|
||||||
|
|
@ -152,6 +240,166 @@ export function MachineCategoryReport() {
|
||||||
onTimeRangeChange={(value) => setTimeRange(value)}
|
onTimeRangeChange={(value) => setTimeRange(value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Card className="border-slate-200">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-semibold text-neutral-900">
|
||||||
|
Filtro detalhado por máquina e usuário
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-neutral-600">
|
||||||
|
Refine os dados para uma máquina específica e, opcionalmente, para um colaborador da empresa selecionada.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
Máquina
|
||||||
|
</span>
|
||||||
|
<SearchableCombobox
|
||||||
|
value={selectedMachineId}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
Usuário (solicitante)
|
||||||
|
</span>
|
||||||
|
<SearchableCombobox
|
||||||
|
value={selectedUserId}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
Contexto
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
Combine os filtros acima com o período selecionado para analisar o histórico daquela máquina.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{selectedMachineId !== "all" ? (
|
||||||
|
<Card className="border-slate-200">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg font-semibold text-neutral-900">
|
||||||
|
Histórico da máquina selecionada
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-neutral-600">
|
||||||
|
Distribuição de categorias e horas trabalhadas na máquina filtrada, dentro do período selecionado.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{(() => {
|
||||||
|
const machineItems = items.filter(
|
||||||
|
(item) =>
|
||||||
|
item.machineId === selectedMachineId ||
|
||||||
|
(selectedMachineId === "all" && item.machineId !== null),
|
||||||
|
)
|
||||||
|
const totalTickets = machineItems.reduce((acc, item) => acc + item.total, 0)
|
||||||
|
const hoursItem = (hours?.items ?? []).find(
|
||||||
|
(entry) => entry.machineId === selectedMachineId,
|
||||||
|
)
|
||||||
|
const internalHours = hoursItem ? hoursItem.internalMs / 3_600_000 : 0
|
||||||
|
const externalHours = hoursItem ? hoursItem.externalMs / 3_600_000 : 0
|
||||||
|
const totalHours = hoursItem ? hoursItem.totalMs / 3_600_000 : 0
|
||||||
|
|
||||||
|
const companyLabel =
|
||||||
|
hoursItem?.companyName ??
|
||||||
|
machineItems[0]?.companyName ??
|
||||||
|
(companyId === "all" ? "Várias empresas" : null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1 rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
Chamados da máquina
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-semibold text-neutral-900">{totalTickets}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
Horas totais
|
||||||
|
</p>
|
||||||
|
<p className="text-xl font-semibold text-neutral-900">
|
||||||
|
{totalHours > 0 ? formatHoursCompact(totalHours) : "0h"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
Internas: {formatHoursCompact(internalHours)} · Externas:{" "}
|
||||||
|
{formatHoursCompact(externalHours)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
Empresa
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold text-neutral-900">
|
||||||
|
{companyLabel ?? "Sem empresa"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const perCategory = new Map<string, { categoryName: string; total: number }>()
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.machineId !== selectedMachineId) continue
|
||||||
|
if (selectedUserId !== "all" && selectedUserId) {
|
||||||
|
// user filter já é aplicado na query de backend; não precisamos revalidar aqui
|
||||||
|
}
|
||||||
|
const key = item.categoryName || "Sem categoria"
|
||||||
|
const current = perCategory.get(key) ?? { categoryName: key, total: 0 }
|
||||||
|
current.total += item.total
|
||||||
|
perCategory.set(key, current)
|
||||||
|
}
|
||||||
|
const rows = Array.from(perCategory.values()).sort((a, b) => b.total - a.total)
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="rounded-lg border border-dashed border-slate-200 p-4 text-sm text-neutral-500">
|
||||||
|
Nenhuma categoria encontrada para a combinação de filtros atual.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-slate-200">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-neutral-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left">Categoria</th>
|
||||||
|
<th className="px-4 py-3 text-right">Chamados</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr key={row.categoryName} className="border-t border-slate-100">
|
||||||
|
<td className="px-4 py-2 text-neutral-800">{row.categoryName}</td>
|
||||||
|
<td className="px-4 py-2 text-right font-semibold text-neutral-900">
|
||||||
|
{row.total}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<Card className="border-slate-200">
|
<Card className="border-slate-200">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue