feat: add granular filters to machines inventory export
This commit is contained in:
parent
a3d431efa8
commit
3880ff57bd
2 changed files with 272 additions and 18 deletions
|
|
@ -20,6 +20,8 @@ export async function GET(request: Request) {
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const companyId = searchParams.get("companyId") ?? undefined
|
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 client = new ConvexHttpClient(convexUrl)
|
||||||
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
@ -50,8 +52,16 @@ export async function GET(request: Request) {
|
||||||
})) as MachineInventoryRecord[]
|
})) as MachineInventoryRecord[]
|
||||||
|
|
||||||
const filtered = machines.filter((machine) => {
|
const filtered = machines.filter((machine) => {
|
||||||
if (!companyId) return true
|
if (companyId) {
|
||||||
return String(machine.companyId ?? "") === companyId || machine.companySlug === 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 = (() => {
|
const companyFilterLabel = (() => {
|
||||||
if (!companyId) return null
|
if (!companyId) return null
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { Spinner } from "@/components/ui/spinner"
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
import { Progress } from "@/components/ui/progress"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
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" }
|
return { label: "Sem prioridade", badgeClass: "border border-slate-200 bg-slate-100 text-neutral-600" }
|
||||||
}
|
}
|
||||||
const normalized = priority.toUpperCase()
|
const normalized = priority.toUpperCase()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
TICKET_PRIORITY_META[normalized] ?? {
|
TICKET_PRIORITY_META[normalized] ?? {
|
||||||
label: priority.charAt(0).toUpperCase() + priority.slice(1).toLowerCase(),
|
label: priority.charAt(0).toUpperCase() + priority.slice(1).toLowerCase(),
|
||||||
|
|
@ -1195,7 +1197,13 @@ export function AdminMachinesOverview({ tenantId, initialCompanyFilterSlug = "al
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all")
|
const [statusFilter, setStatusFilter] = useState<string>("all")
|
||||||
const [companyFilterSlug, setCompanyFilterSlug] = useState<string>(initialCompanyFilterSlug)
|
const [companyFilterSlug, setCompanyFilterSlug] = useState<string>(initialCompanyFilterSlug)
|
||||||
const [companySearch, setCompanySearch] = useState<string>("")
|
const [companySearch, setCompanySearch] = useState<string>("")
|
||||||
|
const [isCompanyPopoverOpen, setIsCompanyPopoverOpen] = useState(false)
|
||||||
const [onlyAlerts, setOnlyAlerts] = useState<boolean>(false)
|
const [onlyAlerts, setOnlyAlerts] = useState<boolean>(false)
|
||||||
|
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false)
|
||||||
|
const [exportSelection, setExportSelection] = useState<string[]>([])
|
||||||
|
const [isExporting, setIsExporting] = useState(false)
|
||||||
|
const [exportProgress, setExportProgress] = useState(0)
|
||||||
|
const [exportError, setExportError] = useState<string | null>(null)
|
||||||
const { convexUserId } = useAuth()
|
const { convexUserId } = useAuth()
|
||||||
const companies = useQuery(
|
const companies = useQuery(
|
||||||
convexUserId ? api.companies.list : "skip",
|
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"))
|
.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
|
||||||
}, [companies, machines])
|
}, [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 filteredMachines = useMemo(() => {
|
||||||
const text = q.trim().toLowerCase()
|
const text = q.trim().toLowerCase()
|
||||||
return machines.filter((m) => {
|
return machines.filter((m) => {
|
||||||
|
|
@ -1263,6 +1262,150 @@ export function AdminMachinesOverview({ tenantId, initialCompanyFilterSlug = "al
|
||||||
return hay.includes(text)
|
return hay.includes(text)
|
||||||
})
|
})
|
||||||
}, [machines, q, statusFilter, companyFilterSlug, onlyAlerts])
|
}, [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 (
|
return (
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
|
|
@ -1288,7 +1431,7 @@ export function AdminMachinesOverview({ tenantId, initialCompanyFilterSlug = "al
|
||||||
<SelectItem value="unknown">Desconhecido</SelectItem>
|
<SelectItem value="unknown">Desconhecido</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Popover>
|
<Popover open={isCompanyPopoverOpen} onOpenChange={setIsCompanyPopoverOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" className="min-w-56 justify-between">
|
<Button variant="outline" className="min-w-56 justify-between">
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|
@ -1309,7 +1452,7 @@ export function AdminMachinesOverview({ tenantId, initialCompanyFilterSlug = "al
|
||||||
<div className="max-h-64 overflow-auto rounded-md border border-slate-200">
|
<div className="max-h-64 overflow-auto rounded-md border border-slate-200">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setCompanyFilterSlug("all"); setCompanySearch("") }}
|
onClick={() => { setCompanyFilterSlug("all"); setCompanySearch(""); setIsCompanyPopoverOpen(false) }}
|
||||||
className="block w-full px-3 py-2 text-left text-sm hover:bg-slate-100"
|
className="block w-full px-3 py-2 text-left text-sm hover:bg-slate-100"
|
||||||
>
|
>
|
||||||
Todas empresas
|
Todas empresas
|
||||||
|
|
@ -1320,7 +1463,7 @@ export function AdminMachinesOverview({ tenantId, initialCompanyFilterSlug = "al
|
||||||
<button
|
<button
|
||||||
key={c.slug}
|
key={c.slug}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setCompanyFilterSlug(c.slug); setCompanySearch("") }}
|
onClick={() => { setCompanyFilterSlug(c.slug); setCompanySearch(""); setIsCompanyPopoverOpen(false) }}
|
||||||
className="block w-full px-3 py-2 text-left text-sm hover:bg-slate-100"
|
className="block w-full px-3 py-2 text-left text-sm hover:bg-slate-100"
|
||||||
>
|
>
|
||||||
{c.name}
|
{c.name}
|
||||||
|
|
@ -1335,10 +1478,9 @@ export function AdminMachinesOverview({ tenantId, initialCompanyFilterSlug = "al
|
||||||
<span>Somente com alertas</span>
|
<span>Somente com alertas</span>
|
||||||
</label>
|
</label>
|
||||||
<Button variant="outline" onClick={() => { setQ(""); setStatusFilter("all"); setCompanyFilterSlug("all"); setCompanySearch(""); setOnlyAlerts(false) }}>Limpar</Button>
|
<Button variant="outline" onClick={() => { setQ(""); setStatusFilter("all"); setCompanyFilterSlug("all"); setCompanySearch(""); setOnlyAlerts(false) }}>Limpar</Button>
|
||||||
<Button asChild size="sm" variant="outline">
|
<Button size="sm" variant="outline" className="gap-2" onClick={handleOpenExportDialog}>
|
||||||
<a href={exportHref} download>
|
<Download className="size-4" />
|
||||||
Exportar XLSX
|
Exportar XLSX
|
||||||
</a>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|
@ -1350,6 +1492,108 @@ export function AdminMachinesOverview({ tenantId, initialCompanyFilterSlug = "al
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Dialog open={isExportDialogOpen} onOpenChange={handleExportDialogOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl space-y-5">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Exportar inventário</DialogTitle>
|
||||||
|
<DialogDescription>Revise as máquinas antes de gerar o XLSX.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{filteredMachines.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed border-slate-200 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Nenhuma máquina disponível para exportar com os filtros atuais.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600">
|
||||||
|
<span>
|
||||||
|
{selectedCount} de {filteredMachines.length} selecionadas
|
||||||
|
</span>
|
||||||
|
<label className="inline-flex items-center gap-2 font-medium text-slate-600">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectAllState}
|
||||||
|
onCheckedChange={(value) => handleSelectAllMachines(value === true || value === "indeterminate")}
|
||||||
|
disabled={isExporting}
|
||||||
|
/>
|
||||||
|
<span>Selecionar todas</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-80 overflow-y-auto rounded-md border border-slate-200">
|
||||||
|
<ul className="divide-y divide-slate-100">
|
||||||
|
{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 (
|
||||||
|
<li key={machine.id}>
|
||||||
|
<label className="flex cursor-pointer items-start gap-3 px-3 py-3 hover:bg-slate-50">
|
||||||
|
<Checkbox
|
||||||
|
checked={isChecked}
|
||||||
|
onCheckedChange={(value) => handleToggleMachineSelection(machine.id, value === true || value === "indeterminate")}
|
||||||
|
disabled={isExporting}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1 text-sm">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium text-slate-900">{machine.hostname || machine.authEmail || "Máquina"}</span>
|
||||||
|
<Badge variant="outline" className="text-[11px] font-medium uppercase tracking-wide">
|
||||||
|
{statusLabel}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||||
|
<span>{machine.companyName ?? "Sem empresa"}</span>
|
||||||
|
{osLabel ? (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-300">•</span>
|
||||||
|
<span>{osLabel}</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{machine.architecture ? (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-300">•</span>
|
||||||
|
<span>{machine.architecture}</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isExporting ? (
|
||||||
|
<div className="space-y-2 rounded-md border border-slate-200 bg-slate-50 px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between text-sm font-medium text-slate-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}
|
||||||
|
<DialogFooter className="gap-2 sm:gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => handleExportDialogOpenChange(false)} disabled={isExporting}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={handleConfirmExport} disabled={isExporting || selectedCount === 0} className="gap-2">
|
||||||
|
{isExporting ? (
|
||||||
|
<>
|
||||||
|
<Spinner className="mr-2 size-4" />
|
||||||
|
Exportando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="size-4" />
|
||||||
|
Exportar{selectedCount > 0 ? ` (${selectedCount})` : ""}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue