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>