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:
parent
49aa143a80
commit
063c5dfde7
11 changed files with 1448 additions and 26 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue