diff --git a/src/app/api/reports/machines-inventory.xlsx/route.ts b/src/app/api/reports/machines-inventory.xlsx/route.ts index 5efa357..41ad574 100644 --- a/src/app/api/reports/machines-inventory.xlsx/route.ts +++ b/src/app/api/reports/machines-inventory.xlsx/route.ts @@ -20,6 +20,8 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url) const companyId = searchParams.get("companyId") ?? undefined + const machineIdParams = searchParams.getAll("machineId").filter(Boolean) + const machineIdFilter = machineIdParams.length > 0 ? new Set(machineIdParams) : null const client = new ConvexHttpClient(convexUrl) const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID @@ -50,8 +52,16 @@ export async function GET(request: Request) { })) as MachineInventoryRecord[] const filtered = machines.filter((machine) => { - if (!companyId) return true - return String(machine.companyId ?? "") === companyId || machine.companySlug === companyId + if (companyId) { + const companyMatches = String(machine.companyId ?? "") === companyId || (machine.companySlug ?? "") === companyId + if (!companyMatches) { + return false + } + } + if (machineIdFilter && !machineIdFilter.has(String(machine.id))) { + return false + } + return true }) const companyFilterLabel = (() => { if (!companyId) return null diff --git a/src/components/admin/machines/admin-machines-overview.tsx b/src/components/admin/machines/admin-machines-overview.tsx index 1db5d02..5cef84e 100644 --- a/src/components/admin/machines/admin-machines-overview.tsx +++ b/src/components/admin/machines/admin-machines-overview.tsx @@ -38,6 +38,7 @@ import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { Spinner } from "@/components/ui/spinner" +import { Progress } from "@/components/ui/progress" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Checkbox } from "@/components/ui/checkbox" @@ -890,6 +891,7 @@ function getTicketPriorityMeta(priority: TicketPriority | string | null | undefi return { label: "Sem prioridade", badgeClass: "border border-slate-200 bg-slate-100 text-neutral-600" } } const normalized = priority.toUpperCase() + return ( TICKET_PRIORITY_META[normalized] ?? { label: priority.charAt(0).toUpperCase() + priority.slice(1).toLowerCase(), @@ -1195,7 +1197,13 @@ export function AdminMachinesOverview({ tenantId, initialCompanyFilterSlug = "al const [statusFilter, setStatusFilter] = useState("all") const [companyFilterSlug, setCompanyFilterSlug] = useState(initialCompanyFilterSlug) const [companySearch, setCompanySearch] = useState("") + const [isCompanyPopoverOpen, setIsCompanyPopoverOpen] = useState(false) const [onlyAlerts, setOnlyAlerts] = useState(false) + const [isExportDialogOpen, setIsExportDialogOpen] = useState(false) + const [exportSelection, setExportSelection] = useState([]) + const [isExporting, setIsExporting] = useState(false) + const [exportProgress, setExportProgress] = useState(0) + const [exportError, setExportError] = useState(null) const { convexUserId } = useAuth() const companies = useQuery( convexUserId ? api.companies.list : "skip", @@ -1233,15 +1241,6 @@ export function AdminMachinesOverview({ tenantId, initialCompanyFilterSlug = "al .sort((a, b) => a.name.localeCompare(b.name, "pt-BR")) }, [companies, machines]) - const exportHref = useMemo(() => { - const params = new URLSearchParams() - if (companyFilterSlug !== "all") { - params.set("companyId", companyFilterSlug) - } - const qs = params.toString() - return `/api/reports/machines-inventory.xlsx${qs ? `?${qs}` : ""}` - }, [companyFilterSlug]) - const filteredMachines = useMemo(() => { const text = q.trim().toLowerCase() return machines.filter((m) => { @@ -1263,6 +1262,150 @@ export function AdminMachinesOverview({ tenantId, initialCompanyFilterSlug = "al return hay.includes(text) }) }, [machines, q, statusFilter, companyFilterSlug, onlyAlerts]) + const handleOpenExportDialog = useCallback(() => { + if (filteredMachines.length === 0) { + toast.info("Não há máquinas para exportar com os filtros atuais.") + return + } + setExportSelection(filteredMachines.map((m) => m.id)) + setExportProgress(0) + setExportError(null) + setIsExporting(false) + setIsExportDialogOpen(true) + }, [filteredMachines]) + + const handleExportDialogOpenChange = useCallback((open: boolean) => { + if (!open && isExporting) return + setIsExportDialogOpen(open) + }, [isExporting]) + + useEffect(() => { + if (!isExportDialogOpen) { + setExportSelection([]) + setExportProgress(0) + setExportError(null) + setIsExporting(false) + } + }, [isExportDialogOpen]) + + useEffect(() => { + if (!isExportDialogOpen) return + const allowed = new Set(filteredMachines.map((m) => m.id)) + setExportSelection((prev) => { + const next = prev.filter((id) => allowed.has(id)) + return next.length === prev.length ? prev : next + }) + }, [filteredMachines, isExportDialogOpen]) + + const handleToggleMachineSelection = useCallback((machineId: string, checked: boolean) => { + setExportSelection((prev) => { + if (checked) { + if (prev.includes(machineId)) return prev + return [...prev, machineId] + } + return prev.filter((id) => id !== machineId) + }) + }, []) + + const handleSelectAllMachines = useCallback((checked: boolean) => { + if (checked) { + setExportSelection(filteredMachines.map((m) => m.id)) + } else { + setExportSelection([]) + } + }, [filteredMachines]) + + const handleConfirmExport = useCallback(async () => { + const orderedSelection = filteredMachines.map((m) => m.id).filter((id) => exportSelection.includes(id)) + if (orderedSelection.length === 0) { + toast.info("Selecione ao menos uma máquina para exportar.") + return + } + + setIsExporting(true) + setExportError(null) + setExportProgress(5) + + try { + const params = new URLSearchParams() + if (companyFilterSlug !== "all") { + params.set("companyId", companyFilterSlug) + } + orderedSelection.forEach((id) => params.append("machineId", id)) + const qs = params.toString() + const url = `/api/reports/machines-inventory.xlsx${qs ? `?${qs}` : ""}` + + 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] ?? `machines-inventory-${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) { + chunks.push(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength)) + 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(`Exportação gerada para ${orderedSelection.length} máquina${orderedSelection.length === 1 ? "" : "s"}.`) + setIsExporting(false) + setIsExportDialogOpen(false) + } catch (error) { + console.error("Failed to export machines inventory", error) + setIsExporting(false) + setExportProgress(0) + setExportError("Não foi possível gerar o arquivo. Tente novamente.") + } + }, [companyFilterSlug, exportSelection, filteredMachines]) + + const exportableCount = filteredMachines.length + const selectedCount = exportSelection.length + const selectAllState: boolean | "indeterminate" = exportableCount === 0 + ? false + : selectedCount === exportableCount + ? true + : selectedCount > 0 + ? "indeterminate" + : false + + return (
@@ -1288,7 +1431,7 @@ export function AdminMachinesOverview({ tenantId, initialCompanyFilterSlug = "al Desconhecido - + -
{isLoading ? ( @@ -1350,6 +1492,108 @@ export function AdminMachinesOverview({ tenantId, initialCompanyFilterSlug = "al )} + + + + Exportar inventário + Revise as máquinas antes de gerar o XLSX. + + {filteredMachines.length === 0 ? ( +
+ Nenhuma máquina disponível para exportar com os filtros atuais. +
+ ) : ( +
+
+ + {selectedCount} de {filteredMachines.length} selecionadas + + +
+
+
    + {filteredMachines.map((machine) => { + const statusKey = resolveMachineStatus(machine) + const statusLabel = statusLabels[statusKey] ?? statusKey + const isChecked = exportSelection.includes(machine.id) + const osParts = [machine.osName ?? "", machine.osVersion ?? ""].filter(Boolean) + const osLabel = osParts.join(" ") + return ( +
  • + +
  • + ) + })} +
+
+
+ )} + {isExporting ? ( +
+
+ Gerando planilha... + {Math.min(100, Math.max(0, Math.round(exportProgress)))}% +
+ +
+ ) : null} + {exportError ?

{exportError}

: null} + + + + +
+
) }