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
|
|
@ -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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
|
|
@ -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
356
convex/emprestimos.ts
Normal 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),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -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"]),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
762
src/app/emprestimos/emprestimos-page-client.tsx
Normal file
762
src/app/emprestimos/emprestimos-page-client.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
src/app/emprestimos/page.tsx
Normal file
13
src/app/emprestimos/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue