feat: configurable machine report export
This commit is contained in:
parent
c1ce7f1ab9
commit
a7f9191e1d
4 changed files with 414 additions and 33 deletions
|
|
@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
|
||||||
|
|
||||||
import { assertAuthenticatedSession } from "@/lib/auth-server"
|
import { assertAuthenticatedSession } from "@/lib/auth-server"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
import type { DeviceInventoryColumnConfig } from "@/lib/device-inventory-columns"
|
||||||
import { buildMachineCategoryWorkbook, createConvexContext } from "@/server/report-exporters"
|
import { buildMachineCategoryWorkbook, createConvexContext } from "@/server/report-exporters"
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
|
|
@ -19,6 +20,31 @@ export async function GET(request: Request) {
|
||||||
const userId = searchParams.get("userId") ?? undefined
|
const userId = searchParams.get("userId") ?? undefined
|
||||||
const dateFrom = searchParams.get("dateFrom") ?? undefined
|
const dateFrom = searchParams.get("dateFrom") ?? undefined
|
||||||
const dateTo = searchParams.get("dateTo") ?? undefined
|
const dateTo = searchParams.get("dateTo") ?? undefined
|
||||||
|
const columnsParam = searchParams.get("columns")
|
||||||
|
let columns: DeviceInventoryColumnConfig[] | undefined
|
||||||
|
if (columnsParam) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(columnsParam)
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
columns = parsed
|
||||||
|
.map((item) => {
|
||||||
|
if (typeof item === "string") {
|
||||||
|
return { key: item }
|
||||||
|
}
|
||||||
|
if (item && typeof item === "object" && typeof item.key === "string") {
|
||||||
|
return {
|
||||||
|
key: item.key,
|
||||||
|
label: typeof item.label === "string" && item.label.length > 0 ? item.label : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
.filter((item): item is DeviceInventoryColumnConfig => item !== null)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Invalid columns parameter for machine category export", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
||||||
|
|
@ -38,6 +64,7 @@ export async function GET(request: Request) {
|
||||||
userId: userId || undefined,
|
userId: userId || undefined,
|
||||||
dateFrom,
|
dateFrom,
|
||||||
dateTo,
|
dateTo,
|
||||||
|
columns,
|
||||||
})
|
})
|
||||||
|
|
||||||
return new NextResponse(artifact.buffer, {
|
return new NextResponse(artifact.buffer, {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { useQuery } from "convex/react"
|
import { useQuery } from "convex/react"
|
||||||
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
|
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
|
||||||
|
|
||||||
|
|
@ -15,6 +15,20 @@ import { SearchableCombobox, type SearchableComboboxOption } from "@/components/
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||||
import { formatDateDM, formatHoursCompact } from "@/lib/utils"
|
import { formatDateDM, formatHoursCompact } from "@/lib/utils"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { DEVICE_INVENTORY_COLUMN_METADATA, type DeviceInventoryColumnConfig } from "@/lib/device-inventory-columns"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { Progress } from "@/components/ui/progress"
|
||||||
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
type MachineCategoryDailyItem = {
|
type MachineCategoryDailyItem = {
|
||||||
date: string
|
date: string
|
||||||
|
|
@ -47,6 +61,20 @@ type MachineHoursResponse = {
|
||||||
items: MachineHoursItem[]
|
items: MachineHoursItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DeviceFieldOption = {
|
||||||
|
id: Id<"deviceFields">
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ColumnOption = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
group: "base" | "custom"
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_DEVICE_COLUMN_KEYS = DEVICE_INVENTORY_COLUMN_METADATA.filter((meta) => meta.default !== false).map((meta) => meta.key)
|
||||||
|
|
||||||
export function MachineCategoryReport() {
|
export function MachineCategoryReport() {
|
||||||
const [timeRange, setTimeRange] = useState<"90d" | "30d" | "7d" | "365d" | "all">("30d")
|
const [timeRange, setTimeRange] = useState<"90d" | "30d" | "7d" | "365d" | "all">("30d")
|
||||||
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
||||||
|
|
@ -119,6 +147,16 @@ export function MachineCategoryReport() {
|
||||||
: "skip"
|
: "skip"
|
||||||
) as Array<{ id: Id<"users">; name: string | null; email: string; companyId?: Id<"companies"> | null }> | undefined
|
) as Array<{ id: Id<"users">; name: string | null; email: string; companyId?: Id<"companies"> | null }> | undefined
|
||||||
|
|
||||||
|
const deviceFields = useQuery(
|
||||||
|
api.deviceFields.listForTenant,
|
||||||
|
enabled
|
||||||
|
? ({
|
||||||
|
tenantId,
|
||||||
|
viewerId: convexUserId as Id<"users">,
|
||||||
|
} as const)
|
||||||
|
: "skip"
|
||||||
|
) as DeviceFieldOption[] | undefined
|
||||||
|
|
||||||
const machineOptions = useMemo<SearchableComboboxOption[]>(() => {
|
const machineOptions = useMemo<SearchableComboboxOption[]>(() => {
|
||||||
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as máquinas" }]
|
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as máquinas" }]
|
||||||
if (!machinesRaw || machinesRaw.length === 0) return base
|
if (!machinesRaw || machinesRaw.length === 0) return base
|
||||||
|
|
@ -157,17 +195,57 @@ export function MachineCategoryReport() {
|
||||||
|
|
||||||
const [selectedMachineId, setSelectedMachineId] = useState<string>("all")
|
const [selectedMachineId, setSelectedMachineId] = useState<string>("all")
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string>("all")
|
const [selectedUserId, setSelectedUserId] = useState<string>("all")
|
||||||
|
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false)
|
||||||
|
const [isExporting, setIsExporting] = useState(false)
|
||||||
|
const [exportProgress, setExportProgress] = useState(0)
|
||||||
|
const [exportError, setExportError] = useState<string | null>(null)
|
||||||
|
const [selectedColumnKeys, setSelectedColumnKeys] = useState<string[]>(() => [...DEFAULT_DEVICE_COLUMN_KEYS])
|
||||||
|
|
||||||
const exportHref = useMemo(() => {
|
const baseColumnOptions = useMemo<ColumnOption[]>(
|
||||||
const params = new URLSearchParams()
|
() =>
|
||||||
params.set("range", timeRange)
|
DEVICE_INVENTORY_COLUMN_METADATA.map((meta) => ({
|
||||||
if (companyId !== "all") params.set("companyId", companyId)
|
key: meta.key,
|
||||||
if (selectedMachineId !== "all") params.set("machineId", selectedMachineId)
|
label: meta.label,
|
||||||
if (selectedUserId !== "all") params.set("userId", selectedUserId)
|
group: "base" as const,
|
||||||
if (dateFrom) params.set("dateFrom", dateFrom)
|
})),
|
||||||
if (dateTo) params.set("dateTo", dateTo)
|
[]
|
||||||
return `/api/reports/machine-category.xlsx?${params.toString()}`
|
)
|
||||||
}, [companyId, dateFrom, dateTo, selectedMachineId, selectedUserId, timeRange])
|
|
||||||
|
const customColumnOptions = useMemo<ColumnOption[]>(() => {
|
||||||
|
if (!deviceFields || deviceFields.length === 0) return []
|
||||||
|
return deviceFields
|
||||||
|
.map((field) => ({
|
||||||
|
key: `custom:${field.key}`,
|
||||||
|
label: field.label,
|
||||||
|
group: "custom" as const,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label, "pt-BR"))
|
||||||
|
}, [deviceFields])
|
||||||
|
|
||||||
|
const columnOptionsMap = useMemo(() => {
|
||||||
|
const entries = [...baseColumnOptions, ...customColumnOptions]
|
||||||
|
return new Map(entries.map((option) => [option.key, option]))
|
||||||
|
}, [baseColumnOptions, customColumnOptions])
|
||||||
|
|
||||||
|
const orderedColumnKeys = useMemo(
|
||||||
|
() => [...baseColumnOptions.map((option) => option.key), ...customColumnOptions.map((option) => option.key)],
|
||||||
|
[baseColumnOptions, customColumnOptions]
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedColumnConfigs = useMemo<DeviceInventoryColumnConfig[]>(() => {
|
||||||
|
const selection = new Set(selectedColumnKeys)
|
||||||
|
return orderedColumnKeys
|
||||||
|
.filter((key) => selection.has(key))
|
||||||
|
.map((key) => {
|
||||||
|
const option = columnOptionsMap.get(key)
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label: option && option.group === "custom" ? option.label : undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [columnOptionsMap, orderedColumnKeys, selectedColumnKeys])
|
||||||
|
|
||||||
|
const selectedColumnCount = selectedColumnConfigs.length
|
||||||
|
|
||||||
const hours = useQuery(
|
const hours = useQuery(
|
||||||
api.reports.hoursByMachine,
|
api.reports.hoursByMachine,
|
||||||
|
|
@ -228,6 +306,144 @@ export function MachineCategoryReport() {
|
||||||
[items]
|
[items]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const exportableMachineIds = useMemo(() => {
|
||||||
|
const set = new Set<string>()
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.machineId) {
|
||||||
|
set.add(String(item.machineId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(set)
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
const handleToggleColumn = useCallback((key: string, checked: boolean) => {
|
||||||
|
setSelectedColumnKeys((prev) => {
|
||||||
|
if (checked) {
|
||||||
|
if (prev.includes(key)) return prev
|
||||||
|
return [...prev, key]
|
||||||
|
}
|
||||||
|
return prev.filter((column) => column !== key)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleResetColumns = useCallback(() => {
|
||||||
|
setSelectedColumnKeys([...DEFAULT_DEVICE_COLUMN_KEYS])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSelectAllColumns = useCallback(() => {
|
||||||
|
setSelectedColumnKeys([...orderedColumnKeys])
|
||||||
|
}, [orderedColumnKeys])
|
||||||
|
|
||||||
|
const handleOpenExportDialog = useCallback(() => {
|
||||||
|
if (exportableMachineIds.length === 0) {
|
||||||
|
toast.warning("Nenhuma máquina com identificador válido para exportar neste período.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setExportError(null)
|
||||||
|
setIsExportDialogOpen(true)
|
||||||
|
}, [exportableMachineIds.length])
|
||||||
|
|
||||||
|
const handleExportDialogOpenChange = useCallback(
|
||||||
|
(open: boolean) => {
|
||||||
|
if (!open && isExporting) return
|
||||||
|
setIsExportDialogOpen(open)
|
||||||
|
if (!open) {
|
||||||
|
setExportProgress(0)
|
||||||
|
setExportError(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isExporting]
|
||||||
|
)
|
||||||
|
|
||||||
|
const buildExportSearchParams = useCallback(() => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set("range", timeRange)
|
||||||
|
if (companyId !== "all") params.set("companyId", companyId)
|
||||||
|
if (selectedMachineId !== "all") params.set("machineId", selectedMachineId)
|
||||||
|
if (selectedUserId !== "all") params.set("userId", selectedUserId)
|
||||||
|
if (dateFrom) params.set("dateFrom", dateFrom)
|
||||||
|
if (dateTo) params.set("dateTo", dateTo)
|
||||||
|
return params
|
||||||
|
}, [companyId, dateFrom, dateTo, selectedMachineId, selectedUserId, timeRange])
|
||||||
|
|
||||||
|
const handleConfirmExport = useCallback(async () => {
|
||||||
|
if (exportableMachineIds.length === 0) {
|
||||||
|
setExportError("Não há máquinas disponíveis para exportar.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedColumnConfigs.length === 0) {
|
||||||
|
setExportError("Selecione ao menos uma coluna para exportar.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const params = buildExportSearchParams()
|
||||||
|
params.set("columns", JSON.stringify(selectedColumnConfigs))
|
||||||
|
const qs = params.toString()
|
||||||
|
const url = `/api/reports/machine-category.xlsx${qs ? `?${qs}` : ""}`
|
||||||
|
|
||||||
|
setIsExporting(true)
|
||||||
|
setExportError(null)
|
||||||
|
setExportProgress(5)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Export failed with status ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLengthHeader = response.headers.get("Content-Length")
|
||||||
|
const totalBytes = contentLengthHeader ? parseInt(contentLengthHeader, 10) : Number.NaN
|
||||||
|
const hasLength = Number.isFinite(totalBytes) && totalBytes > 0
|
||||||
|
const disposition = response.headers.get("Content-Disposition")
|
||||||
|
const filenameMatch = disposition?.match(/filename="?([^";]+)"?/i)
|
||||||
|
const filename = filenameMatch?.[1] ?? `machine-category-${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||||
|
|
||||||
|
let blob: Blob
|
||||||
|
if (!response.body || typeof response.body.getReader !== "function") {
|
||||||
|
blob = await response.blob()
|
||||||
|
setExportProgress(100)
|
||||||
|
} else {
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const chunks: ArrayBuffer[] = []
|
||||||
|
let received = 0
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
if (value) {
|
||||||
|
const chunk = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength)
|
||||||
|
chunks.push(chunk)
|
||||||
|
received += value.length
|
||||||
|
if (hasLength) {
|
||||||
|
const percent = Math.min(99, Math.round((received / totalBytes) * 100))
|
||||||
|
setExportProgress(percent)
|
||||||
|
} else {
|
||||||
|
setExportProgress((prev) => (prev >= 95 ? prev : prev + 5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
blob = new Blob(chunks, { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" })
|
||||||
|
setExportProgress(100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadUrl = window.URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement("a")
|
||||||
|
link.href = downloadUrl
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
window.URL.revokeObjectURL(downloadUrl)
|
||||||
|
|
||||||
|
toast.success("Planilha gerada com sucesso.")
|
||||||
|
setIsExporting(false)
|
||||||
|
setIsExportDialogOpen(false)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to export machine category worksheet", error)
|
||||||
|
setExportProgress(0)
|
||||||
|
setIsExporting(false)
|
||||||
|
setExportError("Não foi possível gerar a planilha. Tente novamente.")
|
||||||
|
}
|
||||||
|
}, [buildExportSearchParams, exportableMachineIds.length, selectedColumnConfigs])
|
||||||
|
|
||||||
if (!canView) {
|
if (!canView) {
|
||||||
return (
|
return (
|
||||||
<Card className="border-slate-200">
|
<Card className="border-slate-200">
|
||||||
|
|
@ -270,7 +486,8 @@ export function MachineCategoryReport() {
|
||||||
setDateTo(to)
|
setDateTo(to)
|
||||||
}}
|
}}
|
||||||
allowExtendedRanges
|
allowExtendedRanges
|
||||||
exportHref={exportHref}
|
onExportClick={handleOpenExportDialog}
|
||||||
|
isExporting={isExporting}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card className="border-slate-200">
|
<Card className="border-slate-200">
|
||||||
|
|
@ -568,6 +785,107 @@ export function MachineCategoryReport() {
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={isExportDialogOpen} onOpenChange={handleExportDialogOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Exportar XLSX — Máquinas x categorias</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Personalize quais campos das máquinas aparecerão na planilha complementar de inventário. Os filtros aplicados neste relatório serão respeitados.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-slate-50/70 px-4 py-3 text-sm text-neutral-700">
|
||||||
|
Máquinas encontradas:{" "}
|
||||||
|
<span className="font-semibold text-neutral-900">{exportableMachineIds.length}</span>
|
||||||
|
{selectedMachineId !== "all" ? (
|
||||||
|
<span className="ml-1 text-xs text-neutral-500">(apenas a máquina filtrada será exportada)</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p className="text-sm font-semibold text-neutral-900">Colunas selecionadas: {selectedColumnCount}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={handleSelectAllColumns} disabled={isExporting} className="gap-2">
|
||||||
|
Selecionar todas
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={handleResetColumns} disabled={isExporting} className="gap-2">
|
||||||
|
Restaurar padrão
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 max-h-[420px] overflow-y-auto pr-1">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Colunas padrão</p>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{baseColumnOptions.map((column) => {
|
||||||
|
const checked = selectedColumnKeys.includes(column.key)
|
||||||
|
return (
|
||||||
|
<label key={column.key} className="flex items-center gap-2 text-sm text-neutral-800">
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(value) => handleToggleColumn(column.key, value === true || value === "indeterminate")}
|
||||||
|
disabled={isExporting}
|
||||||
|
/>
|
||||||
|
<span>{column.label}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{customColumnOptions.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Campos personalizados</p>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{customColumnOptions.map((column) => {
|
||||||
|
const checked = selectedColumnKeys.includes(column.key)
|
||||||
|
return (
|
||||||
|
<label key={column.key} className="flex items-center gap-2 text-sm text-neutral-800">
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(value) => handleToggleColumn(column.key, value === true || value === "indeterminate")}
|
||||||
|
disabled={isExporting}
|
||||||
|
/>
|
||||||
|
<span>{column.label}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{isExporting ? (
|
||||||
|
<div className="space-y-2 rounded-xl border border-slate-200 bg-white/80 px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between text-sm font-medium text-neutral-700">
|
||||||
|
<span>Gerando planilha...</span>
|
||||||
|
<span>{Math.min(100, Math.max(0, Math.round(exportProgress)))}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={exportProgress} className="h-2" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{exportError ? <p className="text-sm text-destructive">{exportError}</p> : null}
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => handleExportDialogOpenChange(false)} disabled={isExporting}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirmExport}
|
||||||
|
disabled={isExporting || selectedColumnCount === 0 || exportableMachineIds.length === 0}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isExporting ? (
|
||||||
|
<>
|
||||||
|
<Spinner className="mr-2 size-4" />
|
||||||
|
Exportando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Exportar XLSX"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,7 @@ function deriveMachineData(machine: MachineInventoryRecord): MachineDerivedData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeColumnConfig(columns?: DeviceInventoryColumnConfig[]): DeviceInventoryColumnConfig[] {
|
export function normalizeInventoryColumnConfig(columns?: DeviceInventoryColumnConfig[]): DeviceInventoryColumnConfig[] {
|
||||||
if (!columns || columns.length === 0) {
|
if (!columns || columns.length === 0) {
|
||||||
return [...DEFAULT_COLUMN_CONFIG]
|
return [...DEFAULT_COLUMN_CONFIG]
|
||||||
}
|
}
|
||||||
|
|
@ -425,14 +425,7 @@ export function buildMachinesInventoryWorkbook(
|
||||||
): Buffer {
|
): Buffer {
|
||||||
const generatedAt = options.generatedAt ?? new Date()
|
const generatedAt = options.generatedAt ?? new Date()
|
||||||
const summaryRows = buildSummaryRows(machines, options, generatedAt)
|
const summaryRows = buildSummaryRows(machines, options, generatedAt)
|
||||||
const columnConfig = normalizeColumnConfig(options.columns)
|
const inventorySheet = buildInventoryWorksheet(machines, options.columns)
|
||||||
const derivedList = machines.map((machine) => deriveMachineData(machine))
|
|
||||||
const headers = columnConfig.map((column) => resolveColumnLabel(column, derivedList))
|
|
||||||
const columnWidths = columnConfig.map((column) => resolveColumnWidth(column.key))
|
|
||||||
const inventoryRows = machines.map((machine, index) => {
|
|
||||||
const derived = derivedList[index]
|
|
||||||
return columnConfig.map((column) => formatInventoryCell(resolveColumnValue(column.key, machine, derived)))
|
|
||||||
})
|
|
||||||
const linksRows = buildLinkedUsersRows(machines)
|
const linksRows = buildLinkedUsersRows(machines)
|
||||||
const softwareRows = buildSoftwareRows(machines)
|
const softwareRows = buildSoftwareRows(machines)
|
||||||
const partitionRows = buildPartitionRows(machines)
|
const partitionRows = buildPartitionRows(machines)
|
||||||
|
|
@ -454,14 +447,7 @@ export function buildMachinesInventoryWorkbook(
|
||||||
columnWidths: [28, 48],
|
columnWidths: [28, 48],
|
||||||
})
|
})
|
||||||
|
|
||||||
sheets.push({
|
sheets.push(inventorySheet)
|
||||||
name: "Inventário",
|
|
||||||
headers,
|
|
||||||
rows: inventoryRows,
|
|
||||||
columnWidths,
|
|
||||||
freezePane: { rowSplit: 1 },
|
|
||||||
autoFilter: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
sheets.push({
|
sheets.push({
|
||||||
name: "Vínculos",
|
name: "Vínculos",
|
||||||
|
|
@ -583,6 +569,32 @@ export function buildMachinesInventoryWorkbook(
|
||||||
return buildXlsxWorkbook(sheets)
|
return buildXlsxWorkbook(sheets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildInventoryWorksheet(
|
||||||
|
machines: MachineInventoryRecord[],
|
||||||
|
columns?: DeviceInventoryColumnConfig[],
|
||||||
|
sheetName = "Inventário",
|
||||||
|
): WorksheetConfig {
|
||||||
|
const columnConfig = normalizeInventoryColumnConfig(columns)
|
||||||
|
const derivedList = machines.map((machine) => deriveMachineData(machine))
|
||||||
|
const headers = columnConfig.map((column) => resolveColumnLabel(column, derivedList))
|
||||||
|
const columnWidths = columnConfig.map((column) => resolveColumnWidth(column.key))
|
||||||
|
const inventoryRows = machines.map((machine, index) => {
|
||||||
|
const derived = derivedList[index]
|
||||||
|
return columnConfig.map((column) => formatInventoryCell(resolveColumnValue(column.key, machine, derived)))
|
||||||
|
})
|
||||||
|
const fallbackRows =
|
||||||
|
inventoryRows.length > 0 ? inventoryRows : [columnConfig.map(() => "—")]
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: sheetName,
|
||||||
|
headers,
|
||||||
|
rows: fallbackRows,
|
||||||
|
columnWidths,
|
||||||
|
freezePane: { rowSplit: 1 },
|
||||||
|
autoFilter: inventoryRows.length > 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildSummaryRows(
|
function buildSummaryRows(
|
||||||
machines: MachineInventoryRecord[],
|
machines: MachineInventoryRecord[],
|
||||||
options: WorkbookOptions,
|
options: WorkbookOptions,
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ import { ConvexHttpClient } from "convex/browser"
|
||||||
|
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import type { Id } from "@/convex/_generated/dataModel"
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
import { buildXlsxWorkbook } from "@/lib/xlsx"
|
import { buildXlsxWorkbook, type WorksheetConfig } from "@/lib/xlsx"
|
||||||
import { REPORT_EXPORT_DEFINITIONS, type ReportExportKey } from "@/lib/report-definitions"
|
import { REPORT_EXPORT_DEFINITIONS, type ReportExportKey } from "@/lib/report-definitions"
|
||||||
import { requireConvexUrl } from "@/server/convex-client"
|
import { requireConvexUrl } from "@/server/convex-client"
|
||||||
|
import type { DeviceInventoryColumnConfig } from "@/lib/device-inventory-columns"
|
||||||
|
import { buildInventoryWorksheet, type MachineInventoryRecord } from "@/server/machines/inventory-export"
|
||||||
export type { ReportExportKey }
|
export type { ReportExportKey }
|
||||||
|
|
||||||
type ViewerIdentity = {
|
type ViewerIdentity = {
|
||||||
|
|
@ -205,7 +207,7 @@ export async function buildCategoryInsightsWorkbook(
|
||||||
|
|
||||||
export async function buildMachineCategoryWorkbook(
|
export async function buildMachineCategoryWorkbook(
|
||||||
ctx: ConvexReportContext,
|
ctx: ConvexReportContext,
|
||||||
options: BaseOptions & { machineId?: string | null; userId?: string | null }
|
options: BaseOptions & { machineId?: string | null; userId?: string | null; columns?: DeviceInventoryColumnConfig[] }
|
||||||
): Promise<ReportArtifact> {
|
): Promise<ReportArtifact> {
|
||||||
const response = await ctx.client.query(api.reports.ticketsByMachineAndCategory, {
|
const response = await ctx.client.query(api.reports.ticketsByMachineAndCategory, {
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
|
|
@ -274,7 +276,7 @@ export async function buildMachineCategoryWorkbook(
|
||||||
item.total,
|
item.total,
|
||||||
])
|
])
|
||||||
|
|
||||||
const workbook = buildXlsxWorkbook([
|
const sheets: WorksheetConfig[] = [
|
||||||
{ name: "Resumo", headers: ["Item", "Valor"], rows: summaryRows },
|
{ name: "Resumo", headers: ["Item", "Valor"], rows: summaryRows },
|
||||||
{
|
{
|
||||||
name: "Máquinas",
|
name: "Máquinas",
|
||||||
|
|
@ -286,7 +288,29 @@ export async function buildMachineCategoryWorkbook(
|
||||||
headers: ["Data", "Máquina", "Empresa", "Categoria", "Total"],
|
headers: ["Data", "Máquina", "Empresa", "Categoria", "Total"],
|
||||||
rows: occurrencesRows.length > 0 ? occurrencesRows : [["—", "—", "—", "—", 0]],
|
rows: occurrencesRows.length > 0 ? occurrencesRows : [["—", "—", "—", "—", 0]],
|
||||||
},
|
},
|
||||||
])
|
]
|
||||||
|
|
||||||
|
if (options.columns && options.columns.length > 0) {
|
||||||
|
const machineIds = new Set<string>()
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.machineId) {
|
||||||
|
machineIds.add(String(item.machineId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (machineIds.size > 0) {
|
||||||
|
const machines = (await ctx.client.query(api.devices.listByTenant, {
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
includeMetadata: true,
|
||||||
|
})) as MachineInventoryRecord[]
|
||||||
|
const filteredMachines = machines.filter((machine) => machineIds.has(String(machine.id)))
|
||||||
|
if (filteredMachines.length > 0) {
|
||||||
|
const inventorySheet = buildInventoryWorksheet(filteredMachines, options.columns, "Máquinas detalhadas")
|
||||||
|
sheets.push(inventorySheet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workbook = buildXlsxWorkbook(sheets)
|
||||||
|
|
||||||
const fileName = `machine-category-${ctx.tenantId}-${options.range ?? response.rangeDays ?? "30"}d${
|
const fileName = `machine-category-${ctx.tenantId}-${options.range ?? response.rangeDays ?? "30"}d${
|
||||||
options.companyId ? `-${options.companyId}` : ""
|
options.companyId ? `-${options.companyId}` : ""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue