Add equipment loan feature and USB bulk control

- Add emprestimos (equipment loan) module in Convex with queries/mutations
- Create emprestimos page with full CRUD and status tracking
- Add USB bulk control to admin devices overview
- Fix Portuguese accents in USB policy control component
- Fix dead code warnings in Rust agent
- Fix tiptap type error in rich text editor
This commit is contained in:
rever-tecnologia 2025-12-04 14:23:58 -03:00
parent 49aa143a80
commit 063c5dfde7
11 changed files with 1448 additions and 26 deletions

View file

@ -38,6 +38,8 @@ import {
Plus,
Smartphone,
Tablet,
Usb,
Loader2,
} from "lucide-react"
import { api } from "@/convex/_generated/api"
@ -1263,6 +1265,10 @@ export function AdminDevicesOverview({
const [templateForCompany, setTemplateForCompany] = useState(false)
const [templateAsDefault, setTemplateAsDefault] = useState(false)
const [isSavingTemplate, setIsSavingTemplate] = useState(false)
const [isUsbPolicyDialogOpen, setIsUsbPolicyDialogOpen] = useState(false)
const [selectedUsbPolicy, setSelectedUsbPolicy] = useState<"ALLOW" | "BLOCK_ALL" | "READONLY">("ALLOW")
const [isApplyingUsbPolicy, setIsApplyingUsbPolicy] = useState(false)
const [usbPolicySelection, setUsbPolicySelection] = useState<string[]>([])
const [isCreateDeviceOpen, setIsCreateDeviceOpen] = useState(false)
const [createDeviceLoading, setCreateDeviceLoading] = useState(false)
const [newDeviceName, setNewDeviceName] = useState("")
@ -1386,6 +1392,7 @@ export function AdminDevicesOverview({
const createTemplate = useMutation(api.deviceExportTemplates.create)
const saveDeviceProfileMutation = useMutation(api.devices.saveDeviceProfile)
const bulkSetUsbPolicyMutation = useMutation(api.usbPolicy.bulkSetUsbPolicy)
useEffect(() => {
if (!selectedCompany && templateForCompany) {
@ -1676,6 +1683,69 @@ export function AdminDevicesOverview({
}
}, [filteredDevices])
const handleOpenUsbPolicyDialog = useCallback(() => {
const windowsDevices = filteredDevices.filter(
(m) => (m.devicePlatform ?? "").toLowerCase() === "windows"
)
if (windowsDevices.length === 0) {
toast.info("Não há dispositivos Windows para aplicar política USB.")
return
}
setUsbPolicySelection(windowsDevices.map((m) => m.id))
setSelectedUsbPolicy("ALLOW")
setIsApplyingUsbPolicy(false)
setIsUsbPolicyDialogOpen(true)
}, [filteredDevices])
const handleUsbPolicyDialogOpenChange = useCallback((open: boolean) => {
if (!open && isApplyingUsbPolicy) return
setIsUsbPolicyDialogOpen(open)
}, [isApplyingUsbPolicy])
const handleToggleUsbDeviceSelection = useCallback((deviceId: string, checked: boolean) => {
setUsbPolicySelection((prev) => {
if (checked) {
if (prev.includes(deviceId)) return prev
return [...prev, deviceId]
}
return prev.filter((id) => id !== deviceId)
})
}, [])
const handleSelectAllUsbDevices = useCallback((checked: boolean) => {
const windowsDevices = filteredDevices.filter(
(m) => (m.devicePlatform ?? "").toLowerCase() === "windows"
)
if (checked) {
setUsbPolicySelection(windowsDevices.map((m) => m.id))
} else {
setUsbPolicySelection([])
}
}, [filteredDevices])
const handleApplyBulkUsbPolicy = useCallback(async () => {
if (usbPolicySelection.length === 0) {
toast.info("Selecione ao menos um dispositivo.")
return
}
setIsApplyingUsbPolicy(true)
try {
const result = await bulkSetUsbPolicyMutation({
machineIds: usbPolicySelection.map((id) => id as Id<"machines">),
policy: selectedUsbPolicy,
actorId: convexUserId ? (convexUserId as Id<"users">) : undefined,
})
toast.success(`Política USB aplicada a ${result.successful} de ${result.total} dispositivos.`)
setIsUsbPolicyDialogOpen(false)
} catch (error) {
console.error("[usb-policy] Falha ao aplicar política em massa", error)
toast.error("Falha ao aplicar política USB. Tente novamente.")
} finally {
setIsApplyingUsbPolicy(false)
}
}, [usbPolicySelection, selectedUsbPolicy, convexUserId, bulkSetUsbPolicyMutation])
const handleConfirmExport = useCallback(async () => {
const orderedSelection = filteredDevices.map((m) => m.id).filter((id) => exportSelection.includes(id))
if (orderedSelection.length === 0) {
@ -1902,6 +1972,10 @@ export function AdminDevicesOverview({
<Download className="size-4" />
Exportar XLSX
</Button>
<Button size="sm" variant="outline" className="gap-2" onClick={handleOpenUsbPolicyDialog}>
<Usb className="size-4" />
Controle USB
</Button>
</div>
</div>
{isLoading ? (
@ -2113,6 +2187,138 @@ export function AdminDevicesOverview({
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isUsbPolicyDialogOpen} onOpenChange={handleUsbPolicyDialogOpenChange}>
<DialogContent className="max-w-2xl space-y-5">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Usb className="size-5" />
Controle USB em massa
</DialogTitle>
<DialogDescription>
Aplique uma política de armazenamento USB a todos os dispositivos Windows selecionados.
</DialogDescription>
</DialogHeader>
{(() => {
const windowsDevices = filteredDevices.filter(
(m) => (m.devicePlatform ?? "").toLowerCase() === "windows"
)
if (windowsDevices.length === 0) {
return (
<div className="rounded-md border border-dashed border-slate-200 px-4 py-8 text-center text-sm text-muted-foreground">
Nenhum dispositivo Windows disponível com os filtros atuais.
</div>
)
}
const allSelected = usbPolicySelection.length === windowsDevices.length
const someSelected = usbPolicySelection.length > 0 && usbPolicySelection.length < windowsDevices.length
return (
<div className="space-y-4">
<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>
{usbPolicySelection.length} de {windowsDevices.length} dispositivos Windows selecionados
</span>
<Checkbox
checked={allSelected ? true : someSelected ? "indeterminate" : false}
onCheckedChange={(value) => handleSelectAllUsbDevices(value === true || value === "indeterminate")}
disabled={isApplyingUsbPolicy}
/>
</div>
<div className="max-h-48 space-y-1 overflow-y-auto rounded-md border border-slate-200 p-2">
{windowsDevices.map((device) => {
const checked = usbPolicySelection.includes(device.id)
return (
<label
key={device.id}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 text-sm transition hover:bg-slate-100",
checked && "bg-slate-100"
)}
>
<Checkbox
checked={checked}
onCheckedChange={(value) => handleToggleUsbDeviceSelection(device.id, value === true)}
disabled={isApplyingUsbPolicy}
/>
<span className="flex-1 truncate">
{device.displayName ?? device.hostname}
</span>
<span className="text-xs text-muted-foreground">
{device.companyName ?? "—"}
</span>
</label>
)
})}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">Política USB</label>
<Select
value={selectedUsbPolicy}
onValueChange={(value) => setSelectedUsbPolicy(value as "ALLOW" | "BLOCK_ALL" | "READONLY")}
disabled={isApplyingUsbPolicy}
>
<SelectTrigger>
<SelectValue placeholder="Selecione uma política" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALLOW">
<div className="flex items-center gap-2">
<Shield className="size-4 text-emerald-600" />
<span>Permitido</span>
</div>
</SelectItem>
<SelectItem value="BLOCK_ALL">
<div className="flex items-center gap-2">
<ShieldOff className="size-4 text-red-600" />
<span>Bloqueado</span>
</div>
</SelectItem>
<SelectItem value="READONLY">
<div className="flex items-center gap-2">
<ShieldAlert className="size-4 text-amber-600" />
<span>Somente leitura</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{selectedUsbPolicy === "ALLOW" && "Acesso total a dispositivos USB de armazenamento."}
{selectedUsbPolicy === "BLOCK_ALL" && "Nenhum acesso a dispositivos USB de armazenamento."}
{selectedUsbPolicy === "READONLY" && "Permite leitura, bloqueia escrita em dispositivos USB."}
</p>
</div>
</div>
)
})()}
<DialogFooter className="gap-2 sm:gap-2">
<Button
type="button"
variant="outline"
onClick={() => handleUsbPolicyDialogOpenChange(false)}
disabled={isApplyingUsbPolicy}
>
Cancelar
</Button>
<Button
type="button"
onClick={handleApplyBulkUsbPolicy}
disabled={isApplyingUsbPolicy || usbPolicySelection.length === 0}
className="gap-2"
>
{isApplyingUsbPolicy ? (
<>
<Loader2 className="size-4 animate-spin" />
Aplicando...
</>
) : (
<>
<Usb className="size-4" />
Aplicar política{usbPolicySelection.length > 0 ? ` (${usbPolicySelection.length})` : ""}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isTemplateDialogOpen} onOpenChange={(open) => setIsTemplateDialogOpen(open)}>
<DialogContent className="max-w-md space-y-4">
<DialogHeader>

View file

@ -27,6 +27,18 @@ import { ptBR } from "date-fns/locale"
type UsbPolicyValue = "ALLOW" | "BLOCK_ALL" | "READONLY"
interface UsbPolicyEvent {
id: string
oldPolicy?: string
newPolicy: string
status: string
error?: string
actorEmail?: string
actorName?: string
createdAt: number
appliedAt?: number
}
const POLICY_OPTIONS: Array<{ value: UsbPolicyValue; label: string; description: string; icon: typeof Shield }> = [
{
value: "ALLOW",
@ -123,7 +135,7 @@ export function UsbPolicyControl({
const handleApplyPolicy = async () => {
if (selectedPolicy === usbPolicy?.policy) {
toast.info("A politica selecionada ja esta aplicada.")
toast.info("A política selecionada já está aplicada.")
return
}
@ -136,10 +148,10 @@ export function UsbPolicyControl({
actorEmail,
actorName,
})
toast.success("Politica USB enviada para aplicacao.")
toast.success("Política USB enviada para aplicação.")
} catch (error) {
console.error("[usb-policy] Falha ao aplicar politica", error)
toast.error("Falha ao aplicar politica USB. Tente novamente.")
console.error("[usb-policy] Falha ao aplicar política", error)
toast.error("Falha ao aplicar política USB. Tente novamente.")
} finally {
setIsApplying(false)
}
@ -179,21 +191,21 @@ export function UsbPolicyControl({
{usbPolicy?.error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
<p className="text-sm font-medium text-red-700">Erro na aplicacao</p>
<p className="text-sm font-medium text-red-700">Erro na aplicação</p>
<p className="text-xs text-red-600">{usbPolicy.error}</p>
</div>
)}
<div className="flex items-end gap-2">
<div className="flex-1 space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Alterar politica</label>
<label className="text-xs font-medium text-muted-foreground">Alterar política</label>
<Select
value={selectedPolicy}
onValueChange={(value) => setSelectedPolicy(value as UsbPolicyValue)}
disabled={disabled || isApplying}
>
<SelectTrigger>
<SelectValue placeholder="Selecione uma politica" />
<SelectValue placeholder="Selecione uma política" />
</SelectTrigger>
<SelectContent>
{POLICY_OPTIONS.map((option) => {
@ -230,8 +242,8 @@ export function UsbPolicyControl({
</TooltipTrigger>
<TooltipContent>
{selectedPolicy === usbPolicy?.policy
? "A politica ja esta aplicada"
: `Aplicar politica "${getPolicyConfig(selectedPolicy).label}"`}
? "A política já está aplicada"
: `Aplicar política "${getPolicyConfig(selectedPolicy).label}"`}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -245,17 +257,17 @@ export function UsbPolicyControl({
onClick={() => setShowHistory(!showHistory)}
>
<History className="size-4" />
{showHistory ? "Ocultar historico" : "Ver historico de alteracoes"}
{showHistory ? "Ocultar histórico" : "Ver histórico de alterações"}
</Button>
{showHistory && policyEvents && (
<div className="mt-3 space-y-2">
{policyEvents.length === 0 ? (
<p className="text-center text-xs text-muted-foreground py-2">
Nenhuma alteracao registrada
Nenhuma alteração registrada
</p>
) : (
policyEvents.map((event) => (
policyEvents.map((event: UsbPolicyEvent) => (
<div
key={event.id}
className="flex items-start justify-between rounded-md border bg-white p-2 text-xs"
@ -287,7 +299,7 @@ export function UsbPolicyControl({
{usbPolicy?.reportedAt && (
<p className="text-xs text-muted-foreground">
Ultimo relato do agente: {formatEventDate(usbPolicy.reportedAt)}
Último relato do agente: {formatEventDate(usbPolicy.reportedAt)}
</p>
)}
</CardContent>

View file

@ -1,6 +1,6 @@
"use client"
import * as React from "react"
"use client"
import * as React from "react"
import {
AlertTriangle,
Building,
@ -15,6 +15,7 @@ import {
LayoutTemplate,
LifeBuoy,
MonitorCog,
Package,
PlayCircle,
ShieldAlert,
ShieldCheck,
@ -50,7 +51,7 @@ import { useAuth } from "@/lib/auth-client"
import { cn } from "@/lib/utils"
import type { LucideIcon } from "lucide-react"
type NavRoleRequirement = "staff" | "admin"
type NavigationItem = {
@ -87,6 +88,7 @@ const navigation: NavigationGroup[] = [
{ title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" },
{ title: "Agenda", url: "/agenda", icon: CalendarDays, requiredRole: "staff" },
{ title: "Dispositivos", url: "/admin/devices", icon: MonitorCog, requiredRole: "admin" },
{ title: "Emprestimos", url: "/emprestimos", icon: Package, requiredRole: "staff" },
],
},
{
@ -145,7 +147,7 @@ const navigation: NavigationGroup[] = [
],
},
]
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const pathname = usePathname()
const { session, isLoading, isAdmin, isStaff } = useAuth()
@ -188,18 +190,18 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
React.useEffect(() => {
setIsHydrated(true)
}, [])
function isActive(item: NavigationItem) {
const { url, exact } = item
if (!pathname) return false
if (!pathname) return false
if (url === "/dashboard" && pathname === "/") {
return true
}
return true
}
if (exact) {
return pathname === url
}
return pathname === url || pathname.startsWith(`${url}/`)
}
}
const toggleExpanded = React.useCallback((title: string) => {
setExpanded((prev) => {
@ -212,7 +214,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return next
})
}, [])
if (!isHydrated) {
return (
<Sidebar {...props}>
@ -375,4 +377,4 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</Sidebar>
)
}

View file

@ -646,7 +646,8 @@ const TicketMentionExtension = Mention.extend({
const extensionName = this.name
return ({ node }: { node: { attrs: TicketMentionAttributes; type: { name: string } } }) => {
if (typeof document === "undefined") {
return {}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return null as any
}
const root = document.createElement("a")