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

@ -1134,6 +1134,7 @@ async fn post_heartbeat(
struct UsbPolicyResponse { struct UsbPolicyResponse {
pending: bool, pending: bool,
policy: Option<String>, policy: Option<String>,
#[allow(dead_code)]
applied_at: Option<i64>, applied_at: Option<i64>,
} }

View file

@ -48,6 +48,7 @@ pub struct UsbPolicyResult {
} }
#[derive(Error, Debug)] #[derive(Error, Debug)]
#[allow(dead_code)]
pub enum UsbControlError { pub enum UsbControlError {
#[error("Politica USB invalida: {0}")] #[error("Politica USB invalida: {0}")]
InvalidPolicy(String), InvalidPolicy(String),

View file

@ -20,6 +20,7 @@ import type * as deviceExportTemplates from "../deviceExportTemplates.js";
import type * as deviceFieldDefaults from "../deviceFieldDefaults.js"; import type * as deviceFieldDefaults from "../deviceFieldDefaults.js";
import type * as deviceFields from "../deviceFields.js"; import type * as deviceFields from "../deviceFields.js";
import type * as devices from "../devices.js"; import type * as devices from "../devices.js";
import type * as emprestimos from "../emprestimos.js";
import type * as fields from "../fields.js"; import type * as fields from "../fields.js";
import type * as files from "../files.js"; import type * as files from "../files.js";
import type * as incidents from "../incidents.js"; import type * as incidents from "../incidents.js";
@ -38,6 +39,7 @@ import type * as ticketFormSettings from "../ticketFormSettings.js";
import type * as ticketFormTemplates from "../ticketFormTemplates.js"; import type * as ticketFormTemplates from "../ticketFormTemplates.js";
import type * as ticketNotifications from "../ticketNotifications.js"; import type * as ticketNotifications from "../ticketNotifications.js";
import type * as tickets from "../tickets.js"; import type * as tickets from "../tickets.js";
import type * as usbPolicy from "../usbPolicy.js";
import type * as users from "../users.js"; import type * as users from "../users.js";
import type { import type {
@ -67,6 +69,7 @@ declare const fullApi: ApiFromModules<{
deviceFieldDefaults: typeof deviceFieldDefaults; deviceFieldDefaults: typeof deviceFieldDefaults;
deviceFields: typeof deviceFields; deviceFields: typeof deviceFields;
devices: typeof devices; devices: typeof devices;
emprestimos: typeof emprestimos;
fields: typeof fields; fields: typeof fields;
files: typeof files; files: typeof files;
incidents: typeof incidents; incidents: typeof incidents;
@ -85,6 +88,7 @@ declare const fullApi: ApiFromModules<{
ticketFormTemplates: typeof ticketFormTemplates; ticketFormTemplates: typeof ticketFormTemplates;
ticketNotifications: typeof ticketNotifications; ticketNotifications: typeof ticketNotifications;
tickets: typeof tickets; tickets: typeof tickets;
usbPolicy: typeof usbPolicy;
users: typeof users; users: typeof users;
}>; }>;
declare const fullApiWithMounts: typeof fullApi; declare const fullApiWithMounts: typeof fullApi;

356
convex/emprestimos.ts Normal file
View file

@ -0,0 +1,356 @@
import { v } from "convex/values"
import { mutation, query, type QueryCtx, type MutationCtx } from "./_generated/server"
import type { Id } from "./_generated/dataModel"
const EMPRESTIMO_STATUS = ["ATIVO", "DEVOLVIDO", "ATRASADO", "CANCELADO"] as const
type EmprestimoStatus = (typeof EMPRESTIMO_STATUS)[number]
const EQUIPAMENTO_TIPOS = [
"NOTEBOOK",
"DESKTOP",
"MONITOR",
"TECLADO",
"MOUSE",
"HEADSET",
"WEBCAM",
"IMPRESSORA",
"SCANNER",
"PROJETOR",
"TABLET",
"CELULAR",
"ROTEADOR",
"SWITCH",
"OUTRO",
] as const
async function getNextReference(ctx: MutationCtx, tenantId: string): Promise<number> {
const last = await ctx.db
.query("emprestimos")
.withIndex("by_tenant_reference", (q) => q.eq("tenantId", tenantId))
.order("desc")
.first()
return (last?.reference ?? 0) + 1
}
export const list = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
status: v.optional(v.string()),
clienteId: v.optional(v.id("companies")),
tecnicoId: v.optional(v.id("users")),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const { tenantId, status, clienteId, tecnicoId, limit = 100 } = args
let baseQuery = ctx.db
.query("emprestimos")
.withIndex("by_tenant_created", (q) => q.eq("tenantId", tenantId))
.order("desc")
const all = await baseQuery.take(limit * 2)
let filtered = all
if (status) {
filtered = filtered.filter((e) => e.status === status)
}
if (clienteId) {
filtered = filtered.filter((e) => e.clienteId === clienteId)
}
if (tecnicoId) {
filtered = filtered.filter((e) => e.tecnicoId === tecnicoId)
}
return filtered.slice(0, limit).map((emprestimo) => ({
id: emprestimo._id,
reference: emprestimo.reference,
clienteId: emprestimo.clienteId,
clienteNome: emprestimo.clienteSnapshot.name,
responsavelNome: emprestimo.responsavelNome,
tecnicoId: emprestimo.tecnicoId,
tecnicoNome: emprestimo.tecnicoSnapshot.name,
equipamentos: emprestimo.equipamentos,
quantidade: emprestimo.quantidade,
valor: emprestimo.valor,
dataEmprestimo: emprestimo.dataEmprestimo,
dataFimPrevisto: emprestimo.dataFimPrevisto,
dataDevolucao: emprestimo.dataDevolucao,
status: emprestimo.status,
observacoes: emprestimo.observacoes,
multaDiaria: emprestimo.multaDiaria,
multaCalculada: emprestimo.multaCalculada,
createdAt: emprestimo.createdAt,
updatedAt: emprestimo.updatedAt,
}))
},
})
export const getById = query({
args: {
id: v.id("emprestimos"),
viewerId: v.id("users"),
},
handler: async (ctx, args) => {
const emprestimo = await ctx.db.get(args.id)
if (!emprestimo) return null
return {
id: emprestimo._id,
reference: emprestimo.reference,
clienteId: emprestimo.clienteId,
clienteSnapshot: emprestimo.clienteSnapshot,
responsavelNome: emprestimo.responsavelNome,
responsavelContato: emprestimo.responsavelContato,
tecnicoId: emprestimo.tecnicoId,
tecnicoSnapshot: emprestimo.tecnicoSnapshot,
equipamentos: emprestimo.equipamentos,
quantidade: emprestimo.quantidade,
valor: emprestimo.valor,
dataEmprestimo: emprestimo.dataEmprestimo,
dataFimPrevisto: emprestimo.dataFimPrevisto,
dataDevolucao: emprestimo.dataDevolucao,
status: emprestimo.status,
observacoes: emprestimo.observacoes,
multaDiaria: emprestimo.multaDiaria,
multaCalculada: emprestimo.multaCalculada,
createdBy: emprestimo.createdBy,
createdAt: emprestimo.createdAt,
updatedAt: emprestimo.updatedAt,
}
},
})
export const create = mutation({
args: {
tenantId: v.string(),
createdBy: v.id("users"),
clienteId: v.id("companies"),
responsavelNome: v.string(),
responsavelContato: v.optional(v.string()),
tecnicoId: v.id("users"),
equipamentos: v.array(v.object({
id: v.string(),
tipo: v.string(),
marca: v.string(),
modelo: v.string(),
serialNumber: v.optional(v.string()),
patrimonio: v.optional(v.string()),
})),
valor: v.optional(v.number()),
dataEmprestimo: v.number(),
dataFimPrevisto: v.number(),
observacoes: v.optional(v.string()),
multaDiaria: v.optional(v.number()),
},
handler: async (ctx, args) => {
const now = Date.now()
const reference = await getNextReference(ctx, args.tenantId)
const cliente = await ctx.db.get(args.clienteId)
if (!cliente) {
throw new Error("Cliente nao encontrado")
}
const tecnico = await ctx.db.get(args.tecnicoId)
if (!tecnico) {
throw new Error("Tecnico nao encontrado")
}
const emprestimoId = await ctx.db.insert("emprestimos", {
tenantId: args.tenantId,
reference,
clienteId: args.clienteId,
clienteSnapshot: {
name: cliente.name,
slug: cliente.slug,
},
responsavelNome: args.responsavelNome,
responsavelContato: args.responsavelContato,
tecnicoId: args.tecnicoId,
tecnicoSnapshot: {
name: tecnico.name,
email: tecnico.email,
},
equipamentos: args.equipamentos,
quantidade: args.equipamentos.length,
valor: args.valor,
dataEmprestimo: args.dataEmprestimo,
dataFimPrevisto: args.dataFimPrevisto,
status: "ATIVO",
observacoes: args.observacoes,
multaDiaria: args.multaDiaria,
createdBy: args.createdBy,
createdAt: now,
updatedAt: now,
})
const creator = await ctx.db.get(args.createdBy)
await ctx.db.insert("emprestimoHistorico", {
tenantId: args.tenantId,
emprestimoId,
tipo: "CRIADO",
descricao: `Emprestimo #${reference} criado`,
autorId: args.createdBy,
autorSnapshot: {
name: creator?.name ?? "Sistema",
email: creator?.email,
},
createdAt: now,
})
return { id: emprestimoId, reference }
},
})
export const devolver = mutation({
args: {
id: v.id("emprestimos"),
updatedBy: v.id("users"),
observacoes: v.optional(v.string()),
},
handler: async (ctx, args) => {
const emprestimo = await ctx.db.get(args.id)
if (!emprestimo) {
throw new Error("Emprestimo nao encontrado")
}
if (emprestimo.status === "DEVOLVIDO") {
throw new Error("Emprestimo ja foi devolvido")
}
const now = Date.now()
let multaCalculada: number | undefined
if (emprestimo.multaDiaria && now > emprestimo.dataFimPrevisto) {
const diasAtraso = Math.ceil((now - emprestimo.dataFimPrevisto) / (1000 * 60 * 60 * 24))
multaCalculada = diasAtraso * emprestimo.multaDiaria
}
await ctx.db.patch(args.id, {
status: "DEVOLVIDO",
dataDevolucao: now,
multaCalculada,
observacoes: args.observacoes ?? emprestimo.observacoes,
updatedBy: args.updatedBy,
updatedAt: now,
})
const updater = await ctx.db.get(args.updatedBy)
await ctx.db.insert("emprestimoHistorico", {
tenantId: emprestimo.tenantId,
emprestimoId: args.id,
tipo: "DEVOLVIDO",
descricao: `Emprestimo #${emprestimo.reference} devolvido${multaCalculada ? ` com multa de R$ ${multaCalculada.toFixed(2)}` : ""}`,
alteracoes: { multaCalculada },
autorId: args.updatedBy,
autorSnapshot: {
name: updater?.name ?? "Sistema",
email: updater?.email,
},
createdAt: now,
})
return { ok: true, multaCalculada }
},
})
export const update = mutation({
args: {
id: v.id("emprestimos"),
updatedBy: v.id("users"),
responsavelNome: v.optional(v.string()),
responsavelContato: v.optional(v.string()),
dataFimPrevisto: v.optional(v.number()),
observacoes: v.optional(v.string()),
multaDiaria: v.optional(v.number()),
status: v.optional(v.string()),
},
handler: async (ctx, args) => {
const emprestimo = await ctx.db.get(args.id)
if (!emprestimo) {
throw new Error("Emprestimo nao encontrado")
}
const now = Date.now()
const updates: Record<string, unknown> = {
updatedBy: args.updatedBy,
updatedAt: now,
}
if (args.responsavelNome !== undefined) updates.responsavelNome = args.responsavelNome
if (args.responsavelContato !== undefined) updates.responsavelContato = args.responsavelContato
if (args.dataFimPrevisto !== undefined) updates.dataFimPrevisto = args.dataFimPrevisto
if (args.observacoes !== undefined) updates.observacoes = args.observacoes
if (args.multaDiaria !== undefined) updates.multaDiaria = args.multaDiaria
if (args.status !== undefined) updates.status = args.status
await ctx.db.patch(args.id, updates)
const updater = await ctx.db.get(args.updatedBy)
await ctx.db.insert("emprestimoHistorico", {
tenantId: emprestimo.tenantId,
emprestimoId: args.id,
tipo: "MODIFICADO",
descricao: `Emprestimo #${emprestimo.reference} atualizado`,
alteracoes: updates,
autorId: args.updatedBy,
autorSnapshot: {
name: updater?.name ?? "Sistema",
email: updater?.email,
},
createdAt: now,
})
return { ok: true }
},
})
export const getHistorico = query({
args: {
emprestimoId: v.id("emprestimos"),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const historico = await ctx.db
.query("emprestimoHistorico")
.withIndex("by_emprestimo_created", (q) => q.eq("emprestimoId", args.emprestimoId))
.order("desc")
.take(args.limit ?? 50)
return historico.map((h) => ({
id: h._id,
tipo: h.tipo,
descricao: h.descricao,
alteracoes: h.alteracoes,
autorNome: h.autorSnapshot.name,
createdAt: h.createdAt,
}))
},
})
export const getStats = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
},
handler: async (ctx, args) => {
const all = await ctx.db
.query("emprestimos")
.withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId))
.collect()
const now = Date.now()
const ativos = all.filter((e) => e.status === "ATIVO")
const atrasados = ativos.filter((e) => e.dataFimPrevisto < now)
const devolvidos = all.filter((e) => e.status === "DEVOLVIDO")
return {
total: all.length,
ativos: ativos.length,
atrasados: atrasados.length,
devolvidos: devolvidos.length,
valorTotalAtivo: ativos.reduce((sum, e) => sum + (e.valor ?? 0), 0),
}
},
})

View file

@ -767,4 +767,68 @@ export default defineSchema({
_ttl: v.optional(v.number()), _ttl: v.optional(v.number()),
}) })
.index("by_key", ["tenantId", "cacheKey"]), .index("by_key", ["tenantId", "cacheKey"]),
// ================================
// Emprestimo de Equipamentos
// ================================
emprestimos: defineTable({
tenantId: v.string(),
reference: v.number(),
clienteId: v.id("companies"),
clienteSnapshot: v.object({
name: v.string(),
slug: v.optional(v.string()),
}),
responsavelNome: v.string(),
responsavelContato: v.optional(v.string()),
tecnicoId: v.id("users"),
tecnicoSnapshot: v.object({
name: v.string(),
email: v.optional(v.string()),
}),
equipamentos: v.array(v.object({
id: v.string(),
tipo: v.string(),
marca: v.string(),
modelo: v.string(),
serialNumber: v.optional(v.string()),
patrimonio: v.optional(v.string()),
})),
quantidade: v.number(),
valor: v.optional(v.number()),
dataEmprestimo: v.number(),
dataFimPrevisto: v.number(),
dataDevolucao: v.optional(v.number()),
status: v.string(),
observacoes: v.optional(v.string()),
multaDiaria: v.optional(v.number()),
multaCalculada: v.optional(v.number()),
createdBy: v.id("users"),
updatedBy: v.optional(v.id("users")),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_tenant", ["tenantId"])
.index("by_tenant_status", ["tenantId", "status"])
.index("by_tenant_cliente", ["tenantId", "clienteId"])
.index("by_tenant_tecnico", ["tenantId", "tecnicoId"])
.index("by_tenant_reference", ["tenantId", "reference"])
.index("by_tenant_created", ["tenantId", "createdAt"])
.index("by_tenant_data_fim", ["tenantId", "dataFimPrevisto"]),
emprestimoHistorico: defineTable({
tenantId: v.string(),
emprestimoId: v.id("emprestimos"),
tipo: v.string(),
descricao: v.string(),
alteracoes: v.optional(v.any()),
autorId: v.id("users"),
autorSnapshot: v.object({
name: v.string(),
email: v.optional(v.string()),
}),
createdAt: v.number(),
})
.index("by_emprestimo", ["emprestimoId"])
.index("by_emprestimo_created", ["emprestimoId", "createdAt"]),
}); });

View file

@ -0,0 +1,762 @@
"use client"
import { useState, useMemo, useCallback } from "react"
import { useMutation, useQuery } from "convex/react"
import { format, formatDistanceToNow, isAfter } from "date-fns"
import { ptBR } from "date-fns/locale"
import { toast } from "sonner"
import {
Plus,
Package,
CheckCircle2,
Clock,
AlertTriangle,
Search,
ChevronDown,
RotateCcw,
} from "lucide-react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Spinner } from "@/components/ui/spinner"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { DatePicker } from "@/components/ui/date-picker"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
const EQUIPAMENTO_TIPOS = [
"NOTEBOOK",
"DESKTOP",
"MONITOR",
"TECLADO",
"MOUSE",
"HEADSET",
"WEBCAM",
"IMPRESSORA",
"SCANNER",
"PROJETOR",
"TABLET",
"CELULAR",
"ROTEADOR",
"SWITCH",
"OUTRO",
] as const
type EmprestimoStatus = "ATIVO" | "DEVOLVIDO" | "ATRASADO" | "CANCELADO"
type Equipamento = {
id: string
tipo: string
marca: string
modelo: string
serialNumber?: string
patrimonio?: string
}
type EmprestimoListItem = {
id: string
reference: number
clienteId: string
clienteNome: string
responsavelNome: string
tecnicoId: string
tecnicoNome: string
equipamentos: Equipamento[]
quantidade: number
valor?: number
dataEmprestimo: number
dataFimPrevisto: number
dataDevolucao?: number
status: string
observacoes?: string
multaDiaria?: number
multaCalculada?: number
createdAt: number
updatedAt: number
}
function getStatusBadge(status: string, dataFimPrevisto: number) {
const now = Date.now()
const isAtrasado = status === "ATIVO" && now > dataFimPrevisto
if (isAtrasado) {
return (
<Badge variant="outline" className="gap-1 border-red-200 bg-red-50 text-red-700">
<AlertTriangle className="size-3" />
Atrasado
</Badge>
)
}
switch (status) {
case "ATIVO":
return (
<Badge variant="outline" className="gap-1 border-blue-200 bg-blue-50 text-blue-700">
<Clock className="size-3" />
Ativo
</Badge>
)
case "DEVOLVIDO":
return (
<Badge variant="outline" className="gap-1 border-emerald-200 bg-emerald-50 text-emerald-700">
<CheckCircle2 className="size-3" />
Devolvido
</Badge>
)
case "CANCELADO":
return (
<Badge variant="outline" className="gap-1 border-neutral-200 bg-neutral-50 text-neutral-600">
Cancelado
</Badge>
)
default:
return null
}
}
export function EmprestimosPageClient() {
const { session, convexUserId, role } = useAuth()
const tenantId = session?.user?.tenantId ?? null
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState<string>("all")
const [clienteFilter, setClienteFilter] = useState<string | null>(null)
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [isDevolverDialogOpen, setIsDevolverDialogOpen] = useState(false)
const [selectedEmprestimoId, setSelectedEmprestimoId] = useState<string | null>(null)
// Form states
const [formClienteId, setFormClienteId] = useState<string | null>(null)
const [formResponsavelNome, setFormResponsavelNome] = useState("")
const [formResponsavelContato, setFormResponsavelContato] = useState("")
const [formTecnicoId, setFormTecnicoId] = useState<string | null>(null)
const [formDataEmprestimo, setFormDataEmprestimo] = useState<string | null>(format(new Date(), "yyyy-MM-dd"))
const [formDataFim, setFormDataFim] = useState<string | null>(null)
const [formValor, setFormValor] = useState("")
const [formMultaDiaria, setFormMultaDiaria] = useState("")
const [formObservacoes, setFormObservacoes] = useState("")
const [formEquipamentos, setFormEquipamentos] = useState<Array<{
id: string
tipo: string
marca: string
modelo: string
serialNumber?: string
patrimonio?: string
}>>([])
const [isSubmitting, setIsSubmitting] = useState(false)
// Queries
const emprestimos = useQuery(
api.emprestimos.list,
tenantId && convexUserId
? {
tenantId,
viewerId: convexUserId as Id<"users">,
status: statusFilter !== "all" ? statusFilter : undefined,
clienteId: clienteFilter ? (clienteFilter as Id<"companies">) : undefined,
}
: "skip"
)
const stats = useQuery(
api.emprestimos.getStats,
tenantId && convexUserId
? { tenantId, viewerId: convexUserId as Id<"users"> }
: "skip"
)
const companies = useQuery(
api.companies.list,
tenantId && convexUserId
? { tenantId, viewerId: convexUserId as Id<"users"> }
: "skip"
) as Array<{ id: string; name: string; slug?: string }> | undefined
const agents = useQuery(
api.users.listAgents,
tenantId ? { tenantId } : "skip"
)
// Mutations
const createEmprestimo = useMutation(api.emprestimos.create)
const devolverEmprestimo = useMutation(api.emprestimos.devolver)
const companyOptions = useMemo<SearchableComboboxOption[]>(() => {
return (companies ?? []).map((c) => ({
value: c.id,
label: c.name,
}))
}, [companies])
const userOptions = useMemo<SearchableComboboxOption[]>(() => {
return (agents ?? []).map((u: { _id: string; name: string }) => ({
value: u._id,
label: u.name,
}))
}, [agents])
const filteredEmprestimos = useMemo<EmprestimoListItem[]>(() => {
if (!emprestimos) return []
const list = emprestimos as EmprestimoListItem[]
const q = searchQuery.toLowerCase().trim()
if (!q) return list
return list.filter((e) => {
const searchFields = [
e.clienteNome,
e.responsavelNome,
e.tecnicoNome,
String(e.reference),
...e.equipamentos.map((eq) => `${eq.tipo} ${eq.marca} ${eq.modelo}`),
]
.join(" ")
.toLowerCase()
return searchFields.includes(q)
})
}, [emprestimos, searchQuery])
const handleAddEquipamento = useCallback(() => {
setFormEquipamentos((prev) => [
...prev,
{
id: crypto.randomUUID(),
tipo: "NOTEBOOK",
marca: "",
modelo: "",
},
])
}, [])
const handleRemoveEquipamento = useCallback((id: string) => {
setFormEquipamentos((prev) => prev.filter((eq) => eq.id !== id))
}, [])
const handleEquipamentoChange = useCallback(
(id: string, field: string, value: string) => {
setFormEquipamentos((prev) =>
prev.map((eq) => (eq.id === id ? { ...eq, [field]: value } : eq))
)
},
[]
)
const resetForm = useCallback(() => {
setFormClienteId(null)
setFormResponsavelNome("")
setFormResponsavelContato("")
setFormTecnicoId(null)
setFormDataEmprestimo(format(new Date(), "yyyy-MM-dd"))
setFormDataFim(null)
setFormValor("")
setFormMultaDiaria("")
setFormObservacoes("")
setFormEquipamentos([])
}, [])
const handleCreate = useCallback(async () => {
if (!tenantId || !convexUserId) return
if (!formClienteId || !formTecnicoId || !formDataFim || formEquipamentos.length === 0) {
toast.error("Preencha todos os campos obrigatorios.")
return
}
if (!formResponsavelNome.trim()) {
toast.error("Informe o nome do responsavel.")
return
}
setIsSubmitting(true)
try {
const result = await createEmprestimo({
tenantId,
createdBy: convexUserId as Id<"users">,
clienteId: formClienteId as Id<"companies">,
responsavelNome: formResponsavelNome,
responsavelContato: formResponsavelContato || undefined,
tecnicoId: formTecnicoId as Id<"users">,
equipamentos: formEquipamentos,
valor: formValor ? parseFloat(formValor) : undefined,
dataEmprestimo: formDataEmprestimo ? new Date(formDataEmprestimo).getTime() : Date.now(),
dataFimPrevisto: new Date(formDataFim).getTime(),
observacoes: formObservacoes || undefined,
multaDiaria: formMultaDiaria ? parseFloat(formMultaDiaria) : undefined,
})
toast.success(`Emprestimo #${result.reference} criado com sucesso.`)
setIsCreateDialogOpen(false)
resetForm()
} catch (error) {
console.error("[emprestimos] Falha ao criar", error)
toast.error("Falha ao criar emprestimo.")
} finally {
setIsSubmitting(false)
}
}, [
tenantId,
convexUserId,
formClienteId,
formTecnicoId,
formDataFim,
formEquipamentos,
formResponsavelNome,
formResponsavelContato,
formDataEmprestimo,
formValor,
formMultaDiaria,
formObservacoes,
createEmprestimo,
resetForm,
])
const handleDevolver = useCallback(async () => {
if (!selectedEmprestimoId || !convexUserId) return
setIsSubmitting(true)
try {
const result = await devolverEmprestimo({
id: selectedEmprestimoId as Id<"emprestimos">,
updatedBy: convexUserId as Id<"users">,
observacoes: formObservacoes || undefined,
})
if (result.multaCalculada) {
toast.success(`Emprestimo devolvido com multa de R$ ${result.multaCalculada.toFixed(2)}.`)
} else {
toast.success("Emprestimo devolvido com sucesso.")
}
setIsDevolverDialogOpen(false)
setSelectedEmprestimoId(null)
setFormObservacoes("")
} catch (error) {
console.error("[emprestimos] Falha ao devolver", error)
toast.error("Falha ao registrar devolucao.")
} finally {
setIsSubmitting(false)
}
}, [selectedEmprestimoId, convexUserId, formObservacoes, devolverEmprestimo])
const openDevolverDialog = useCallback((id: string) => {
setSelectedEmprestimoId(id)
setFormObservacoes("")
setIsDevolverDialogOpen(true)
}, [])
if (!tenantId || !convexUserId) {
return (
<div className="flex items-center justify-center py-20">
<Spinner className="size-8" />
</div>
)
}
return (
<div className="space-y-6">
{/* Stats */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardDescription>Total</CardDescription>
<CardTitle className="text-2xl">{stats?.total ?? 0}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Ativos</CardDescription>
<CardTitle className="text-2xl text-blue-600">{stats?.ativos ?? 0}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Atrasados</CardDescription>
<CardTitle className="text-2xl text-red-600">{stats?.atrasados ?? 0}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Valor ativo</CardDescription>
<CardTitle className="text-2xl">
R$ {(stats?.valorTotalAtivo ?? 0).toLocaleString("pt-BR", { minimumFractionDigits: 2 })}
</CardTitle>
</CardHeader>
</Card>
</div>
{/* Filters and Actions */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-4">
<div>
<CardTitle>Emprestimos</CardTitle>
<CardDescription>Gerencie o emprestimo de equipamentos para clientes.</CardDescription>
</div>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="mr-2 size-4" />
Novo emprestimo
</Button>
</CardHeader>
<CardContent>
<div className="mb-4 flex flex-wrap gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Buscar por cliente, responsavel, equipamento..."
className="pl-9"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos status</SelectItem>
<SelectItem value="ATIVO">Ativo</SelectItem>
<SelectItem value="DEVOLVIDO">Devolvido</SelectItem>
</SelectContent>
</Select>
<SearchableCombobox
value={clienteFilter}
onValueChange={setClienteFilter}
options={companyOptions}
placeholder="Filtrar por cliente"
className="w-[200px]"
/>
<Button
variant="outline"
onClick={() => {
setSearchQuery("")
setStatusFilter("all")
setClienteFilter(null)
}}
>
<RotateCcw className="mr-2 size-4" />
Limpar
</Button>
</div>
{/* Table */}
{!emprestimos ? (
<div className="flex items-center justify-center py-10">
<Spinner className="size-8" />
</div>
) : filteredEmprestimos.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-center">
<Package className="mb-4 size-12 text-muted-foreground/50" />
<p className="text-muted-foreground">Nenhum emprestimo encontrado.</p>
</div>
) : (
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Ref</TableHead>
<TableHead>Cliente</TableHead>
<TableHead>Responsavel</TableHead>
<TableHead>Equipamentos</TableHead>
<TableHead>Data emprestimo</TableHead>
<TableHead>Data fim</TableHead>
<TableHead>Status</TableHead>
<TableHead>Valor</TableHead>
<TableHead className="text-right">Acoes</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredEmprestimos.map((emp) => (
<TableRow key={emp.id}>
<TableCell className="font-medium">#{emp.reference}</TableCell>
<TableCell>{emp.clienteNome}</TableCell>
<TableCell>{emp.responsavelNome}</TableCell>
<TableCell>
<span className="text-sm">
{emp.quantidade} item(s):{" "}
{emp.equipamentos
.slice(0, 2)
.map((eq) => eq.tipo)
.join(", ")}
{emp.equipamentos.length > 2 && "..."}
</span>
</TableCell>
<TableCell>
{format(new Date(emp.dataEmprestimo), "dd/MM/yyyy", { locale: ptBR })}
</TableCell>
<TableCell>
{format(new Date(emp.dataFimPrevisto), "dd/MM/yyyy", { locale: ptBR })}
</TableCell>
<TableCell>{getStatusBadge(emp.status, emp.dataFimPrevisto)}</TableCell>
<TableCell>
{emp.valor
? `R$ ${emp.valor.toLocaleString("pt-BR", { minimumFractionDigits: 2 })}`
: "—"}
</TableCell>
<TableCell className="text-right">
{emp.status === "ATIVO" && (
<Button
size="sm"
variant="outline"
onClick={() => openDevolverDialog(emp.id)}
>
<CheckCircle2 className="mr-1 size-3" />
Devolver
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Create Dialog */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Novo emprestimo</DialogTitle>
<DialogDescription>
Registre um novo emprestimo de equipamentos.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">Cliente *</label>
<SearchableCombobox
value={formClienteId}
onValueChange={setFormClienteId}
options={companyOptions}
placeholder="Selecione o cliente"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Tecnico responsavel *</label>
<SearchableCombobox
value={formTecnicoId}
onValueChange={setFormTecnicoId}
options={userOptions}
placeholder="Selecione o tecnico"
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">Nome do responsavel (cliente) *</label>
<Input
value={formResponsavelNome}
onChange={(e) => setFormResponsavelNome(e.target.value)}
placeholder="Nome do responsavel"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Contato do responsavel</label>
<Input
value={formResponsavelContato}
onChange={(e) => setFormResponsavelContato(e.target.value)}
placeholder="Telefone ou e-mail"
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">Data do emprestimo</label>
<DatePicker value={formDataEmprestimo} onChange={setFormDataEmprestimo} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Data prevista devolucao *</label>
<DatePicker value={formDataFim} onChange={setFormDataFim} />
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">Valor total (R$)</label>
<Input
type="number"
step="0.01"
value={formValor}
onChange={(e) => setFormValor(e.target.value)}
placeholder="0,00"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Multa diaria por atraso (R$)</label>
<Input
type="number"
step="0.01"
value={formMultaDiaria}
onChange={(e) => setFormMultaDiaria(e.target.value)}
placeholder="0,00"
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Equipamentos *</label>
<Button type="button" size="sm" variant="outline" onClick={handleAddEquipamento}>
<Plus className="mr-1 size-3" />
Adicionar
</Button>
</div>
{formEquipamentos.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
Nenhum equipamento adicionado.
</p>
) : (
<div className="space-y-3">
{formEquipamentos.map((eq, idx) => (
<div key={eq.id} className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Equipamento {idx + 1}</span>
<Button
type="button"
size="sm"
variant="ghost"
className="text-red-600"
onClick={() => handleRemoveEquipamento(eq.id)}
>
Remover
</Button>
</div>
<div className="grid gap-2 md:grid-cols-4">
<Select
value={eq.tipo}
onValueChange={(v) => handleEquipamentoChange(eq.id, "tipo", v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{EQUIPAMENTO_TIPOS.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder="Marca"
value={eq.marca}
onChange={(e) => handleEquipamentoChange(eq.id, "marca", e.target.value)}
/>
<Input
placeholder="Modelo"
value={eq.modelo}
onChange={(e) => handleEquipamentoChange(eq.id, "modelo", e.target.value)}
/>
<Input
placeholder="Serial/Patrimonio"
value={eq.serialNumber ?? ""}
onChange={(e) =>
handleEquipamentoChange(eq.id, "serialNumber", e.target.value)
}
/>
</div>
</div>
))}
</div>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Observacoes</label>
<Textarea
value={formObservacoes}
onChange={(e) => setFormObservacoes(e.target.value)}
placeholder="Observacoes sobre o emprestimo..."
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
Cancelar
</Button>
<Button onClick={handleCreate} disabled={isSubmitting}>
{isSubmitting ? (
<>
<Spinner className="mr-2 size-4" />
Criando...
</>
) : (
"Criar emprestimo"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Devolver Dialog */}
<Dialog open={isDevolverDialogOpen} onOpenChange={setIsDevolverDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Registrar devolucao</DialogTitle>
<DialogDescription>
Confirme a devolucao dos equipamentos emprestados.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Observacoes da devolucao</label>
<Textarea
value={formObservacoes}
onChange={(e) => setFormObservacoes(e.target.value)}
placeholder="Condicao dos equipamentos, observacoes..."
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsDevolverDialogOpen(false)}>
Cancelar
</Button>
<Button onClick={handleDevolver} disabled={isSubmitting}>
{isSubmitting ? (
<>
<Spinner className="mr-2 size-4" />
Registrando...
</>
) : (
<>
<CheckCircle2 className="mr-2 size-4" />
Confirmar devolucao
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -0,0 +1,13 @@
import { EmprestimosPageClient } from "./emprestimos-page-client"
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
export const dynamic = "force-dynamic"
export default function EmprestimosPage() {
return (
<AppShell header={<SiteHeader title="Emprestimos de Equipamentos" lead="Controle de equipamentos emprestados" />}>
<EmprestimosPageClient />
</AppShell>
)
}

View file

@ -38,6 +38,8 @@ import {
Plus, Plus,
Smartphone, Smartphone,
Tablet, Tablet,
Usb,
Loader2,
} from "lucide-react" } from "lucide-react"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
@ -1263,6 +1265,10 @@ export function AdminDevicesOverview({
const [templateForCompany, setTemplateForCompany] = useState(false) const [templateForCompany, setTemplateForCompany] = useState(false)
const [templateAsDefault, setTemplateAsDefault] = useState(false) const [templateAsDefault, setTemplateAsDefault] = useState(false)
const [isSavingTemplate, setIsSavingTemplate] = 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 [isCreateDeviceOpen, setIsCreateDeviceOpen] = useState(false)
const [createDeviceLoading, setCreateDeviceLoading] = useState(false) const [createDeviceLoading, setCreateDeviceLoading] = useState(false)
const [newDeviceName, setNewDeviceName] = useState("") const [newDeviceName, setNewDeviceName] = useState("")
@ -1386,6 +1392,7 @@ export function AdminDevicesOverview({
const createTemplate = useMutation(api.deviceExportTemplates.create) const createTemplate = useMutation(api.deviceExportTemplates.create)
const saveDeviceProfileMutation = useMutation(api.devices.saveDeviceProfile) const saveDeviceProfileMutation = useMutation(api.devices.saveDeviceProfile)
const bulkSetUsbPolicyMutation = useMutation(api.usbPolicy.bulkSetUsbPolicy)
useEffect(() => { useEffect(() => {
if (!selectedCompany && templateForCompany) { if (!selectedCompany && templateForCompany) {
@ -1676,6 +1683,69 @@ export function AdminDevicesOverview({
} }
}, [filteredDevices]) }, [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 handleConfirmExport = useCallback(async () => {
const orderedSelection = filteredDevices.map((m) => m.id).filter((id) => exportSelection.includes(id)) const orderedSelection = filteredDevices.map((m) => m.id).filter((id) => exportSelection.includes(id))
if (orderedSelection.length === 0) { if (orderedSelection.length === 0) {
@ -1902,6 +1972,10 @@ export function AdminDevicesOverview({
<Download className="size-4" /> <Download className="size-4" />
Exportar XLSX Exportar XLSX
</Button> </Button>
<Button size="sm" variant="outline" className="gap-2" onClick={handleOpenUsbPolicyDialog}>
<Usb className="size-4" />
Controle USB
</Button>
</div> </div>
</div> </div>
{isLoading ? ( {isLoading ? (
@ -2113,6 +2187,138 @@ export function AdminDevicesOverview({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </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)}> <Dialog open={isTemplateDialogOpen} onOpenChange={(open) => setIsTemplateDialogOpen(open)}>
<DialogContent className="max-w-md space-y-4"> <DialogContent className="max-w-md space-y-4">
<DialogHeader> <DialogHeader>

View file

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

View file

@ -15,6 +15,7 @@ import {
LayoutTemplate, LayoutTemplate,
LifeBuoy, LifeBuoy,
MonitorCog, MonitorCog,
Package,
PlayCircle, PlayCircle,
ShieldAlert, ShieldAlert,
ShieldCheck, ShieldCheck,
@ -87,6 +88,7 @@ const navigation: NavigationGroup[] = [
{ title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" }, { title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" },
{ title: "Agenda", url: "/agenda", icon: CalendarDays, requiredRole: "staff" }, { title: "Agenda", url: "/agenda", icon: CalendarDays, requiredRole: "staff" },
{ title: "Dispositivos", url: "/admin/devices", icon: MonitorCog, requiredRole: "admin" }, { title: "Dispositivos", url: "/admin/devices", icon: MonitorCog, requiredRole: "admin" },
{ title: "Emprestimos", url: "/emprestimos", icon: Package, requiredRole: "staff" },
], ],
}, },
{ {

View file

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