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 {
|
||||
pending: bool,
|
||||
policy: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
applied_at: Option<i64>,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ pub struct UsbPolicyResult {
|
|||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub enum UsbControlError {
|
||||
#[error("Politica USB invalida: {0}")]
|
||||
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 deviceFields from "../deviceFields.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 files from "../files.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 ticketNotifications from "../ticketNotifications.js";
|
||||
import type * as tickets from "../tickets.js";
|
||||
import type * as usbPolicy from "../usbPolicy.js";
|
||||
import type * as users from "../users.js";
|
||||
|
||||
import type {
|
||||
|
|
@ -67,6 +69,7 @@ declare const fullApi: ApiFromModules<{
|
|||
deviceFieldDefaults: typeof deviceFieldDefaults;
|
||||
deviceFields: typeof deviceFields;
|
||||
devices: typeof devices;
|
||||
emprestimos: typeof emprestimos;
|
||||
fields: typeof fields;
|
||||
files: typeof files;
|
||||
incidents: typeof incidents;
|
||||
|
|
@ -85,6 +88,7 @@ declare const fullApi: ApiFromModules<{
|
|||
ticketFormTemplates: typeof ticketFormTemplates;
|
||||
ticketNotifications: typeof ticketNotifications;
|
||||
tickets: typeof tickets;
|
||||
usbPolicy: typeof usbPolicy;
|
||||
users: typeof users;
|
||||
}>;
|
||||
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()),
|
||||
})
|
||||
.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,
|
||||
Smartphone,
|
||||
Tablet,
|
||||
Usb,
|
||||
Loader2,
|
||||
} from "lucide-react"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
|
|
@ -1263,6 +1265,10 @@ export function AdminDevicesOverview({
|
|||
const [templateForCompany, setTemplateForCompany] = useState(false)
|
||||
const [templateAsDefault, setTemplateAsDefault] = useState(false)
|
||||
const [isSavingTemplate, setIsSavingTemplate] = useState(false)
|
||||
const [isUsbPolicyDialogOpen, setIsUsbPolicyDialogOpen] = useState(false)
|
||||
const [selectedUsbPolicy, setSelectedUsbPolicy] = useState<"ALLOW" | "BLOCK_ALL" | "READONLY">("ALLOW")
|
||||
const [isApplyingUsbPolicy, setIsApplyingUsbPolicy] = useState(false)
|
||||
const [usbPolicySelection, setUsbPolicySelection] = useState<string[]>([])
|
||||
const [isCreateDeviceOpen, setIsCreateDeviceOpen] = useState(false)
|
||||
const [createDeviceLoading, setCreateDeviceLoading] = useState(false)
|
||||
const [newDeviceName, setNewDeviceName] = useState("")
|
||||
|
|
@ -1386,6 +1392,7 @@ export function AdminDevicesOverview({
|
|||
|
||||
const createTemplate = useMutation(api.deviceExportTemplates.create)
|
||||
const saveDeviceProfileMutation = useMutation(api.devices.saveDeviceProfile)
|
||||
const bulkSetUsbPolicyMutation = useMutation(api.usbPolicy.bulkSetUsbPolicy)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCompany && templateForCompany) {
|
||||
|
|
@ -1676,6 +1683,69 @@ export function AdminDevicesOverview({
|
|||
}
|
||||
}, [filteredDevices])
|
||||
|
||||
const handleOpenUsbPolicyDialog = useCallback(() => {
|
||||
const windowsDevices = filteredDevices.filter(
|
||||
(m) => (m.devicePlatform ?? "").toLowerCase() === "windows"
|
||||
)
|
||||
if (windowsDevices.length === 0) {
|
||||
toast.info("Não há dispositivos Windows para aplicar política USB.")
|
||||
return
|
||||
}
|
||||
setUsbPolicySelection(windowsDevices.map((m) => m.id))
|
||||
setSelectedUsbPolicy("ALLOW")
|
||||
setIsApplyingUsbPolicy(false)
|
||||
setIsUsbPolicyDialogOpen(true)
|
||||
}, [filteredDevices])
|
||||
|
||||
const handleUsbPolicyDialogOpenChange = useCallback((open: boolean) => {
|
||||
if (!open && isApplyingUsbPolicy) return
|
||||
setIsUsbPolicyDialogOpen(open)
|
||||
}, [isApplyingUsbPolicy])
|
||||
|
||||
const handleToggleUsbDeviceSelection = useCallback((deviceId: string, checked: boolean) => {
|
||||
setUsbPolicySelection((prev) => {
|
||||
if (checked) {
|
||||
if (prev.includes(deviceId)) return prev
|
||||
return [...prev, deviceId]
|
||||
}
|
||||
return prev.filter((id) => id !== deviceId)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSelectAllUsbDevices = useCallback((checked: boolean) => {
|
||||
const windowsDevices = filteredDevices.filter(
|
||||
(m) => (m.devicePlatform ?? "").toLowerCase() === "windows"
|
||||
)
|
||||
if (checked) {
|
||||
setUsbPolicySelection(windowsDevices.map((m) => m.id))
|
||||
} else {
|
||||
setUsbPolicySelection([])
|
||||
}
|
||||
}, [filteredDevices])
|
||||
|
||||
const handleApplyBulkUsbPolicy = useCallback(async () => {
|
||||
if (usbPolicySelection.length === 0) {
|
||||
toast.info("Selecione ao menos um dispositivo.")
|
||||
return
|
||||
}
|
||||
|
||||
setIsApplyingUsbPolicy(true)
|
||||
try {
|
||||
const result = await bulkSetUsbPolicyMutation({
|
||||
machineIds: usbPolicySelection.map((id) => id as Id<"machines">),
|
||||
policy: selectedUsbPolicy,
|
||||
actorId: convexUserId ? (convexUserId as Id<"users">) : undefined,
|
||||
})
|
||||
toast.success(`Política USB aplicada a ${result.successful} de ${result.total} dispositivos.`)
|
||||
setIsUsbPolicyDialogOpen(false)
|
||||
} catch (error) {
|
||||
console.error("[usb-policy] Falha ao aplicar política em massa", error)
|
||||
toast.error("Falha ao aplicar política USB. Tente novamente.")
|
||||
} finally {
|
||||
setIsApplyingUsbPolicy(false)
|
||||
}
|
||||
}, [usbPolicySelection, selectedUsbPolicy, convexUserId, bulkSetUsbPolicyMutation])
|
||||
|
||||
const handleConfirmExport = useCallback(async () => {
|
||||
const orderedSelection = filteredDevices.map((m) => m.id).filter((id) => exportSelection.includes(id))
|
||||
if (orderedSelection.length === 0) {
|
||||
|
|
@ -1902,6 +1972,10 @@ export function AdminDevicesOverview({
|
|||
<Download className="size-4" />
|
||||
Exportar XLSX
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="gap-2" onClick={handleOpenUsbPolicyDialog}>
|
||||
<Usb className="size-4" />
|
||||
Controle USB
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
|
|
@ -2113,6 +2187,138 @@ export function AdminDevicesOverview({
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={isUsbPolicyDialogOpen} onOpenChange={handleUsbPolicyDialogOpenChange}>
|
||||
<DialogContent className="max-w-2xl space-y-5">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Usb className="size-5" />
|
||||
Controle USB em massa
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Aplique uma política de armazenamento USB a todos os dispositivos Windows selecionados.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{(() => {
|
||||
const windowsDevices = filteredDevices.filter(
|
||||
(m) => (m.devicePlatform ?? "").toLowerCase() === "windows"
|
||||
)
|
||||
if (windowsDevices.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-slate-200 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Nenhum dispositivo Windows disponível com os filtros atuais.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const allSelected = usbPolicySelection.length === windowsDevices.length
|
||||
const someSelected = usbPolicySelection.length > 0 && usbPolicySelection.length < windowsDevices.length
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600">
|
||||
<span>
|
||||
{usbPolicySelection.length} de {windowsDevices.length} dispositivos Windows selecionados
|
||||
</span>
|
||||
<Checkbox
|
||||
checked={allSelected ? true : someSelected ? "indeterminate" : false}
|
||||
onCheckedChange={(value) => handleSelectAllUsbDevices(value === true || value === "indeterminate")}
|
||||
disabled={isApplyingUsbPolicy}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto rounded-md border border-slate-200 p-2">
|
||||
{windowsDevices.map((device) => {
|
||||
const checked = usbPolicySelection.includes(device.id)
|
||||
return (
|
||||
<label
|
||||
key={device.id}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 text-sm transition hover:bg-slate-100",
|
||||
checked && "bg-slate-100"
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(value) => handleToggleUsbDeviceSelection(device.id, value === true)}
|
||||
disabled={isApplyingUsbPolicy}
|
||||
/>
|
||||
<span className="flex-1 truncate">
|
||||
{device.displayName ?? device.hostname}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{device.companyName ?? "—"}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-700">Política USB</label>
|
||||
<Select
|
||||
value={selectedUsbPolicy}
|
||||
onValueChange={(value) => setSelectedUsbPolicy(value as "ALLOW" | "BLOCK_ALL" | "READONLY")}
|
||||
disabled={isApplyingUsbPolicy}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione uma política" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALLOW">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="size-4 text-emerald-600" />
|
||||
<span>Permitido</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="BLOCK_ALL">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldOff className="size-4 text-red-600" />
|
||||
<span>Bloqueado</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="READONLY">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="size-4 text-amber-600" />
|
||||
<span>Somente leitura</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedUsbPolicy === "ALLOW" && "Acesso total a dispositivos USB de armazenamento."}
|
||||
{selectedUsbPolicy === "BLOCK_ALL" && "Nenhum acesso a dispositivos USB de armazenamento."}
|
||||
{selectedUsbPolicy === "READONLY" && "Permite leitura, bloqueia escrita em dispositivos USB."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleUsbPolicyDialogOpenChange(false)}
|
||||
disabled={isApplyingUsbPolicy}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleApplyBulkUsbPolicy}
|
||||
disabled={isApplyingUsbPolicy || usbPolicySelection.length === 0}
|
||||
className="gap-2"
|
||||
>
|
||||
{isApplyingUsbPolicy ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Aplicando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Usb className="size-4" />
|
||||
Aplicar política{usbPolicySelection.length > 0 ? ` (${usbPolicySelection.length})` : ""}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={isTemplateDialogOpen} onOpenChange={(open) => setIsTemplateDialogOpen(open)}>
|
||||
<DialogContent className="max-w-md space-y-4">
|
||||
<DialogHeader>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,18 @@ import { ptBR } from "date-fns/locale"
|
|||
|
||||
type UsbPolicyValue = "ALLOW" | "BLOCK_ALL" | "READONLY"
|
||||
|
||||
interface UsbPolicyEvent {
|
||||
id: string
|
||||
oldPolicy?: string
|
||||
newPolicy: string
|
||||
status: string
|
||||
error?: string
|
||||
actorEmail?: string
|
||||
actorName?: string
|
||||
createdAt: number
|
||||
appliedAt?: number
|
||||
}
|
||||
|
||||
const POLICY_OPTIONS: Array<{ value: UsbPolicyValue; label: string; description: string; icon: typeof Shield }> = [
|
||||
{
|
||||
value: "ALLOW",
|
||||
|
|
@ -123,7 +135,7 @@ export function UsbPolicyControl({
|
|||
|
||||
const handleApplyPolicy = async () => {
|
||||
if (selectedPolicy === usbPolicy?.policy) {
|
||||
toast.info("A politica selecionada ja esta aplicada.")
|
||||
toast.info("A política selecionada já está aplicada.")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -136,10 +148,10 @@ export function UsbPolicyControl({
|
|||
actorEmail,
|
||||
actorName,
|
||||
})
|
||||
toast.success("Politica USB enviada para aplicacao.")
|
||||
toast.success("Política USB enviada para aplicação.")
|
||||
} catch (error) {
|
||||
console.error("[usb-policy] Falha ao aplicar politica", error)
|
||||
toast.error("Falha ao aplicar politica USB. Tente novamente.")
|
||||
console.error("[usb-policy] Falha ao aplicar política", error)
|
||||
toast.error("Falha ao aplicar política USB. Tente novamente.")
|
||||
} finally {
|
||||
setIsApplying(false)
|
||||
}
|
||||
|
|
@ -179,21 +191,21 @@ export function UsbPolicyControl({
|
|||
|
||||
{usbPolicy?.error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
|
||||
<p className="text-sm font-medium text-red-700">Erro na aplicacao</p>
|
||||
<p className="text-sm font-medium text-red-700">Erro na aplicação</p>
|
||||
<p className="text-xs text-red-600">{usbPolicy.error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Alterar politica</label>
|
||||
<label className="text-xs font-medium text-muted-foreground">Alterar política</label>
|
||||
<Select
|
||||
value={selectedPolicy}
|
||||
onValueChange={(value) => setSelectedPolicy(value as UsbPolicyValue)}
|
||||
disabled={disabled || isApplying}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione uma politica" />
|
||||
<SelectValue placeholder="Selecione uma política" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{POLICY_OPTIONS.map((option) => {
|
||||
|
|
@ -230,8 +242,8 @@ export function UsbPolicyControl({
|
|||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{selectedPolicy === usbPolicy?.policy
|
||||
? "A politica ja esta aplicada"
|
||||
: `Aplicar politica "${getPolicyConfig(selectedPolicy).label}"`}
|
||||
? "A política já está aplicada"
|
||||
: `Aplicar política "${getPolicyConfig(selectedPolicy).label}"`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
|
@ -245,17 +257,17 @@ export function UsbPolicyControl({
|
|||
onClick={() => setShowHistory(!showHistory)}
|
||||
>
|
||||
<History className="size-4" />
|
||||
{showHistory ? "Ocultar historico" : "Ver historico de alteracoes"}
|
||||
{showHistory ? "Ocultar histórico" : "Ver histórico de alterações"}
|
||||
</Button>
|
||||
|
||||
{showHistory && policyEvents && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{policyEvents.length === 0 ? (
|
||||
<p className="text-center text-xs text-muted-foreground py-2">
|
||||
Nenhuma alteracao registrada
|
||||
Nenhuma alteração registrada
|
||||
</p>
|
||||
) : (
|
||||
policyEvents.map((event) => (
|
||||
policyEvents.map((event: UsbPolicyEvent) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="flex items-start justify-between rounded-md border bg-white p-2 text-xs"
|
||||
|
|
@ -287,7 +299,7 @@ export function UsbPolicyControl({
|
|||
|
||||
{usbPolicy?.reportedAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ultimo relato do agente: {formatEventDate(usbPolicy.reportedAt)}
|
||||
Último relato do agente: {formatEventDate(usbPolicy.reportedAt)}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
AlertTriangle,
|
||||
Building,
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
LayoutTemplate,
|
||||
LifeBuoy,
|
||||
MonitorCog,
|
||||
Package,
|
||||
PlayCircle,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
|
|
@ -50,7 +51,7 @@ import { useAuth } from "@/lib/auth-client"
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
|
||||
|
||||
type NavRoleRequirement = "staff" | "admin"
|
||||
|
||||
type NavigationItem = {
|
||||
|
|
@ -87,6 +88,7 @@ const navigation: NavigationGroup[] = [
|
|||
{ title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" },
|
||||
{ title: "Agenda", url: "/agenda", icon: CalendarDays, requiredRole: "staff" },
|
||||
{ title: "Dispositivos", url: "/admin/devices", icon: MonitorCog, requiredRole: "admin" },
|
||||
{ title: "Emprestimos", url: "/emprestimos", icon: Package, requiredRole: "staff" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -145,7 +147,7 @@ const navigation: NavigationGroup[] = [
|
|||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const pathname = usePathname()
|
||||
const { session, isLoading, isAdmin, isStaff } = useAuth()
|
||||
|
|
@ -188,18 +190,18 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
React.useEffect(() => {
|
||||
setIsHydrated(true)
|
||||
}, [])
|
||||
|
||||
|
||||
function isActive(item: NavigationItem) {
|
||||
const { url, exact } = item
|
||||
if (!pathname) return false
|
||||
if (!pathname) return false
|
||||
if (url === "/dashboard" && pathname === "/") {
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (exact) {
|
||||
return pathname === url
|
||||
}
|
||||
return pathname === url || pathname.startsWith(`${url}/`)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExpanded = React.useCallback((title: string) => {
|
||||
setExpanded((prev) => {
|
||||
|
|
@ -212,7 +214,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
|
||||
if (!isHydrated) {
|
||||
return (
|
||||
<Sidebar {...props}>
|
||||
|
|
@ -375,4 +377,4 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
</Sidebar>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -646,7 +646,8 @@ const TicketMentionExtension = Mention.extend({
|
|||
const extensionName = this.name
|
||||
return ({ node }: { node: { attrs: TicketMentionAttributes; type: { name: string } } }) => {
|
||||
if (typeof document === "undefined") {
|
||||
return {}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return null as any
|
||||
}
|
||||
|
||||
const root = document.createElement("a")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue