Add equipment loan feature and USB bulk control

- Add emprestimos (equipment loan) module in Convex with queries/mutations
- Create emprestimos page with full CRUD and status tracking
- Add USB bulk control to admin devices overview
- Fix Portuguese accents in USB policy control component
- Fix dead code warnings in Rust agent
- Fix tiptap type error in rich text editor
This commit is contained in:
rever-tecnologia 2025-12-04 14:23:58 -03:00
parent 49aa143a80
commit 063c5dfde7
11 changed files with 1448 additions and 26 deletions

View file

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