From 063c5dfde7035b52d82c6ce2e40f05f21c2f2de4 Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Thu, 4 Dec 2025 14:23:58 -0300 Subject: [PATCH] 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 --- apps/desktop/src-tauri/src/agent.rs | 1 + apps/desktop/src-tauri/src/usb_control.rs | 1 + convex/_generated/api.d.ts | 4 + convex/emprestimos.ts | 356 ++++++++ convex/schema.ts | 64 ++ .../emprestimos/emprestimos-page-client.tsx | 762 ++++++++++++++++++ src/app/emprestimos/page.tsx | 13 + .../admin/devices/admin-devices-overview.tsx | 206 +++++ .../admin/devices/usb-policy-control.tsx | 38 +- src/components/app-sidebar.tsx | 26 +- src/components/ui/rich-text-editor.tsx | 3 +- 11 files changed, 1448 insertions(+), 26 deletions(-) create mode 100644 convex/emprestimos.ts create mode 100644 src/app/emprestimos/emprestimos-page-client.tsx create mode 100644 src/app/emprestimos/page.tsx diff --git a/apps/desktop/src-tauri/src/agent.rs b/apps/desktop/src-tauri/src/agent.rs index 1ceeb1d..04eb400 100644 --- a/apps/desktop/src-tauri/src/agent.rs +++ b/apps/desktop/src-tauri/src/agent.rs @@ -1134,6 +1134,7 @@ async fn post_heartbeat( struct UsbPolicyResponse { pending: bool, policy: Option, + #[allow(dead_code)] applied_at: Option, } diff --git a/apps/desktop/src-tauri/src/usb_control.rs b/apps/desktop/src-tauri/src/usb_control.rs index 5c352b0..abb959d 100644 --- a/apps/desktop/src-tauri/src/usb_control.rs +++ b/apps/desktop/src-tauri/src/usb_control.rs @@ -48,6 +48,7 @@ pub struct UsbPolicyResult { } #[derive(Error, Debug)] +#[allow(dead_code)] pub enum UsbControlError { #[error("Politica USB invalida: {0}")] InvalidPolicy(String), diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 3c19a3a..8dae31a 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -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; diff --git a/convex/emprestimos.ts b/convex/emprestimos.ts new file mode 100644 index 0000000..d2e9177 --- /dev/null +++ b/convex/emprestimos.ts @@ -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 { + 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 = { + 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), + } + }, +}) diff --git a/convex/schema.ts b/convex/schema.ts index f9e08df..6186306 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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"]), }); diff --git a/src/app/emprestimos/emprestimos-page-client.tsx b/src/app/emprestimos/emprestimos-page-client.tsx new file mode 100644 index 0000000..fed85c8 --- /dev/null +++ b/src/app/emprestimos/emprestimos-page-client.tsx @@ -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 ( + + + Atrasado + + ) + } + + switch (status) { + case "ATIVO": + return ( + + + Ativo + + ) + case "DEVOLVIDO": + return ( + + + Devolvido + + ) + case "CANCELADO": + return ( + + Cancelado + + ) + 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("all") + const [clienteFilter, setClienteFilter] = useState(null) + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) + const [isDevolverDialogOpen, setIsDevolverDialogOpen] = useState(false) + const [selectedEmprestimoId, setSelectedEmprestimoId] = useState(null) + + // Form states + const [formClienteId, setFormClienteId] = useState(null) + const [formResponsavelNome, setFormResponsavelNome] = useState("") + const [formResponsavelContato, setFormResponsavelContato] = useState("") + const [formTecnicoId, setFormTecnicoId] = useState(null) + const [formDataEmprestimo, setFormDataEmprestimo] = useState(format(new Date(), "yyyy-MM-dd")) + const [formDataFim, setFormDataFim] = useState(null) + const [formValor, setFormValor] = useState("") + const [formMultaDiaria, setFormMultaDiaria] = useState("") + const [formObservacoes, setFormObservacoes] = useState("") + const [formEquipamentos, setFormEquipamentos] = useState>([]) + 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(() => { + return (companies ?? []).map((c) => ({ + value: c.id, + label: c.name, + })) + }, [companies]) + + const userOptions = useMemo(() => { + return (agents ?? []).map((u: { _id: string; name: string }) => ({ + value: u._id, + label: u.name, + })) + }, [agents]) + + const filteredEmprestimos = useMemo(() => { + 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 ( +
+ +
+ ) + } + + return ( +
+ {/* Stats */} +
+ + + Total + {stats?.total ?? 0} + + + + + Ativos + {stats?.ativos ?? 0} + + + + + Atrasados + {stats?.atrasados ?? 0} + + + + + Valor ativo + + R$ {(stats?.valorTotalAtivo ?? 0).toLocaleString("pt-BR", { minimumFractionDigits: 2 })} + + + +
+ + {/* Filters and Actions */} + + +
+ Emprestimos + Gerencie o emprestimo de equipamentos para clientes. +
+ +
+ +
+
+ + setSearchQuery(e.target.value)} + placeholder="Buscar por cliente, responsavel, equipamento..." + className="pl-9" + /> +
+ + + +
+ + {/* Table */} + {!emprestimos ? ( +
+ +
+ ) : filteredEmprestimos.length === 0 ? ( +
+ +

Nenhum emprestimo encontrado.

+
+ ) : ( +
+ + + + Ref + Cliente + Responsavel + Equipamentos + Data emprestimo + Data fim + Status + Valor + Acoes + + + + {filteredEmprestimos.map((emp) => ( + + #{emp.reference} + {emp.clienteNome} + {emp.responsavelNome} + + + {emp.quantidade} item(s):{" "} + {emp.equipamentos + .slice(0, 2) + .map((eq) => eq.tipo) + .join(", ")} + {emp.equipamentos.length > 2 && "..."} + + + + {format(new Date(emp.dataEmprestimo), "dd/MM/yyyy", { locale: ptBR })} + + + {format(new Date(emp.dataFimPrevisto), "dd/MM/yyyy", { locale: ptBR })} + + {getStatusBadge(emp.status, emp.dataFimPrevisto)} + + {emp.valor + ? `R$ ${emp.valor.toLocaleString("pt-BR", { minimumFractionDigits: 2 })}` + : "—"} + + + {emp.status === "ATIVO" && ( + + )} + + + ))} + +
+
+ )} +
+
+ + {/* Create Dialog */} + + + + Novo emprestimo + + Registre um novo emprestimo de equipamentos. + + + +
+
+
+ + +
+
+ + +
+
+ +
+
+ + setFormResponsavelNome(e.target.value)} + placeholder="Nome do responsavel" + /> +
+
+ + setFormResponsavelContato(e.target.value)} + placeholder="Telefone ou e-mail" + /> +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + setFormValor(e.target.value)} + placeholder="0,00" + /> +
+
+ + setFormMultaDiaria(e.target.value)} + placeholder="0,00" + /> +
+
+ +
+
+ + +
+ {formEquipamentos.length === 0 ? ( +

+ Nenhum equipamento adicionado. +

+ ) : ( +
+ {formEquipamentos.map((eq, idx) => ( +
+
+ Equipamento {idx + 1} + +
+
+ + handleEquipamentoChange(eq.id, "marca", e.target.value)} + /> + handleEquipamentoChange(eq.id, "modelo", e.target.value)} + /> + + handleEquipamentoChange(eq.id, "serialNumber", e.target.value) + } + /> +
+
+ ))} +
+ )} +
+ +
+ +