diff --git a/src/app/api/reports/machine-category.xlsx/route.ts b/src/app/api/reports/machine-category.xlsx/route.ts index 15e64e1..572c2de 100644 --- a/src/app/api/reports/machine-category.xlsx/route.ts +++ b/src/app/api/reports/machine-category.xlsx/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server" import { assertAuthenticatedSession } from "@/lib/auth-server" import { DEFAULT_TENANT_ID } from "@/lib/constants" +import type { DeviceInventoryColumnConfig } from "@/lib/device-inventory-columns" import { buildMachineCategoryWorkbook, createConvexContext } from "@/server/report-exporters" export const runtime = "nodejs" @@ -19,6 +20,31 @@ export async function GET(request: Request) { const userId = searchParams.get("userId") ?? undefined const dateFrom = searchParams.get("dateFrom") ?? 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 @@ -38,6 +64,7 @@ export async function GET(request: Request) { userId: userId || undefined, dateFrom, dateTo, + columns, }) return new NextResponse(artifact.buffer, { diff --git a/src/components/reports/machine-category-report.tsx b/src/components/reports/machine-category-report.tsx index a124076..09a188d 100644 --- a/src/components/reports/machine-category-report.tsx +++ b/src/components/reports/machine-category-report.tsx @@ -1,6 +1,6 @@ "use client" -import { useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { useQuery } from "convex/react" 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 { formatDateDM, formatHoursCompact } from "@/lib/utils" 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 = { date: string @@ -47,6 +61,20 @@ type MachineHoursResponse = { 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() { const [timeRange, setTimeRange] = useState<"90d" | "30d" | "7d" | "365d" | "all">("30d") const [companyId, setCompanyId] = usePersistentCompanyFilter("all") @@ -119,6 +147,16 @@ export function MachineCategoryReport() { : "skip" ) 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(() => { const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as máquinas" }] if (!machinesRaw || machinesRaw.length === 0) return base @@ -157,17 +195,57 @@ export function MachineCategoryReport() { const [selectedMachineId, setSelectedMachineId] = useState("all") const [selectedUserId, setSelectedUserId] = useState("all") + const [isExportDialogOpen, setIsExportDialogOpen] = useState(false) + const [isExporting, setIsExporting] = useState(false) + const [exportProgress, setExportProgress] = useState(0) + const [exportError, setExportError] = useState(null) + const [selectedColumnKeys, setSelectedColumnKeys] = useState(() => [...DEFAULT_DEVICE_COLUMN_KEYS]) - const exportHref = useMemo(() => { - 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 `/api/reports/machine-category.xlsx?${params.toString()}` - }, [companyId, dateFrom, dateTo, selectedMachineId, selectedUserId, timeRange]) + const baseColumnOptions = useMemo( + () => + DEVICE_INVENTORY_COLUMN_METADATA.map((meta) => ({ + key: meta.key, + label: meta.label, + group: "base" as const, + })), + [] + ) + + const customColumnOptions = useMemo(() => { + 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(() => { + 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( api.reports.hoursByMachine, @@ -228,6 +306,144 @@ export function MachineCategoryReport() { [items] ) + const exportableMachineIds = useMemo(() => { + const set = new Set() + 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) { return ( @@ -270,7 +486,8 @@ export function MachineCategoryReport() { setDateTo(to) }} allowExtendedRanges - exportHref={exportHref} + onExportClick={handleOpenExportDialog} + isExporting={isExporting} /> @@ -568,6 +785,107 @@ export function MachineCategoryReport() { )} + + + + + Exportar XLSX — Máquinas x categorias + + Personalize quais campos das máquinas aparecerão na planilha complementar de inventário. Os filtros aplicados neste relatório serão respeitados. + + +
+
+ Máquinas encontradas:{" "} + {exportableMachineIds.length} + {selectedMachineId !== "all" ? ( + (apenas a máquina filtrada será exportada) + ) : null} +
+
+

Colunas selecionadas: {selectedColumnCount}

+
+ + +
+
+
+
+

Colunas padrão

+
+ {baseColumnOptions.map((column) => { + const checked = selectedColumnKeys.includes(column.key) + return ( + + ) + })} +
+
+ {customColumnOptions.length > 0 ? ( +
+

Campos personalizados

+
+ {customColumnOptions.map((column) => { + const checked = selectedColumnKeys.includes(column.key) + return ( + + ) + })} +
+
+ ) : null} +
+ {isExporting ? ( +
+
+ Gerando planilha... + {Math.min(100, Math.max(0, Math.round(exportProgress)))}% +
+ +
+ ) : null} + {exportError ?

{exportError}

: null} +
+ + + + +
+
) } diff --git a/src/server/machines/inventory-export.ts b/src/server/machines/inventory-export.ts index 871f704..62cee97 100644 --- a/src/server/machines/inventory-export.ts +++ b/src/server/machines/inventory-export.ts @@ -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) { return [...DEFAULT_COLUMN_CONFIG] } @@ -425,14 +425,7 @@ export function buildMachinesInventoryWorkbook( ): Buffer { const generatedAt = options.generatedAt ?? new Date() const summaryRows = buildSummaryRows(machines, options, generatedAt) - const columnConfig = normalizeColumnConfig(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 inventorySheet = buildInventoryWorksheet(machines, options.columns) const linksRows = buildLinkedUsersRows(machines) const softwareRows = buildSoftwareRows(machines) const partitionRows = buildPartitionRows(machines) @@ -454,14 +447,7 @@ export function buildMachinesInventoryWorkbook( columnWidths: [28, 48], }) - sheets.push({ - name: "Inventário", - headers, - rows: inventoryRows, - columnWidths, - freezePane: { rowSplit: 1 }, - autoFilter: true, - }) + sheets.push(inventorySheet) sheets.push({ name: "Vínculos", @@ -583,6 +569,32 @@ export function buildMachinesInventoryWorkbook( 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( machines: MachineInventoryRecord[], options: WorkbookOptions, diff --git a/src/server/report-exporters.ts b/src/server/report-exporters.ts index 31ed95e..854a2e9 100644 --- a/src/server/report-exporters.ts +++ b/src/server/report-exporters.ts @@ -4,9 +4,11 @@ import { ConvexHttpClient } from "convex/browser" import { api } from "@/convex/_generated/api" 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 { 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 } type ViewerIdentity = { @@ -205,7 +207,7 @@ export async function buildCategoryInsightsWorkbook( export async function buildMachineCategoryWorkbook( ctx: ConvexReportContext, - options: BaseOptions & { machineId?: string | null; userId?: string | null } + options: BaseOptions & { machineId?: string | null; userId?: string | null; columns?: DeviceInventoryColumnConfig[] } ): Promise { const response = await ctx.client.query(api.reports.ticketsByMachineAndCategory, { tenantId: ctx.tenantId, @@ -274,7 +276,7 @@ export async function buildMachineCategoryWorkbook( item.total, ]) - const workbook = buildXlsxWorkbook([ + const sheets: WorksheetConfig[] = [ { name: "Resumo", headers: ["Item", "Valor"], rows: summaryRows }, { name: "Máquinas", @@ -286,7 +288,29 @@ export async function buildMachineCategoryWorkbook( headers: ["Data", "Máquina", "Empresa", "Categoria", "Total"], rows: occurrencesRows.length > 0 ? occurrencesRows : [["—", "—", "—", "—", 0]], }, - ]) + ] + + if (options.columns && options.columns.length > 0) { + const machineIds = new Set() + 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${ options.companyId ? `-${options.companyId}` : ""