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
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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue