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

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),
}
},
})