- Update Next.js to 16.0.7 - Fix accent on menu item "Emprestimos" to "Empréstimos" - Standardize loan page with project patterns (DateRangeButton, cyan color scheme, ToggleGroup) - Add company filter to USB bulk policy dialog - Update CardDescription text in devices overview - Fix useEffect dependency warning in desktop main.tsx 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
839 lines
31 KiB
TypeScript
839 lines
31 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useMemo, useCallback } from "react"
|
|
import { useMutation, useQuery } from "convex/react"
|
|
import { format } from "date-fns"
|
|
import { ptBR } from "date-fns/locale"
|
|
import { toast } from "sonner"
|
|
import {
|
|
IconPlus,
|
|
IconPackage,
|
|
IconCircleCheck,
|
|
IconClock,
|
|
IconAlertTriangle,
|
|
IconSearch,
|
|
IconRefresh,
|
|
IconBuilding,
|
|
IconUser,
|
|
IconTrash,
|
|
} from "@tabler/icons-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 {
|
|
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 { DateRangeButton, type DateRangeValue } from "@/components/date-range-button"
|
|
import { DatePicker } from "@/components/ui/date-picker"
|
|
import { Textarea } from "@/components/ui/textarea"
|
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
|
|
|
const EQUIPAMENTO_TIPOS = [
|
|
"NOTEBOOK",
|
|
"DESKTOP",
|
|
"MONITOR",
|
|
"TECLADO",
|
|
"MOUSE",
|
|
"HEADSET",
|
|
"WEBCAM",
|
|
"IMPRESSORA",
|
|
"SCANNER",
|
|
"PROJETOR",
|
|
"TABLET",
|
|
"CELULAR",
|
|
"ROTEADOR",
|
|
"SWITCH",
|
|
"OUTRO",
|
|
] as const
|
|
|
|
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 className="gap-1.5 rounded-full border border-red-200 bg-red-50 px-2.5 py-0.5 text-xs font-medium text-red-700">
|
|
<IconAlertTriangle className="size-3" />
|
|
Atrasado
|
|
</Badge>
|
|
)
|
|
}
|
|
|
|
switch (status) {
|
|
case "ATIVO":
|
|
return (
|
|
<Badge className="gap-1.5 rounded-full border border-cyan-200 bg-cyan-50 px-2.5 py-0.5 text-xs font-medium text-cyan-700">
|
|
<IconClock className="size-3" />
|
|
Ativo
|
|
</Badge>
|
|
)
|
|
case "DEVOLVIDO":
|
|
return (
|
|
<Badge className="gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-2.5 py-0.5 text-xs font-medium text-emerald-700">
|
|
<IconCircleCheck className="size-3" />
|
|
Devolvido
|
|
</Badge>
|
|
)
|
|
case "CANCELADO":
|
|
return (
|
|
<Badge className="gap-1.5 rounded-full border border-slate-200 bg-slate-50 px-2.5 py-0.5 text-xs font-medium text-slate-600">
|
|
Cancelado
|
|
</Badge>
|
|
)
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
const ALL_VALUE = "ALL"
|
|
|
|
export function EmprestimosPageClient() {
|
|
const { session, convexUserId } = 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 [dateRange, setDateRange] = useState<DateRangeValue>({ from: null, to: 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 []
|
|
let list = emprestimos as EmprestimoListItem[]
|
|
|
|
// Filtro por busca
|
|
const q = searchQuery.toLowerCase().trim()
|
|
if (q) {
|
|
list = 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)
|
|
})
|
|
}
|
|
|
|
// Filtro por data
|
|
if (dateRange.from) {
|
|
const fromDate = new Date(dateRange.from).getTime()
|
|
list = list.filter((e) => e.dataEmprestimo >= fromDate)
|
|
}
|
|
if (dateRange.to) {
|
|
const toDate = new Date(dateRange.to).getTime() + 86400000 // +1 dia para incluir o dia todo
|
|
list = list.filter((e) => e.dataEmprestimo <= toDate)
|
|
}
|
|
|
|
return list
|
|
}, [emprestimos, searchQuery, dateRange])
|
|
|
|
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 obrigatórios.")
|
|
return
|
|
}
|
|
if (!formResponsavelNome.trim()) {
|
|
toast.error("Informe o nome do responsável.")
|
|
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(`Empréstimo #${result.reference} criado com sucesso.`)
|
|
setIsCreateDialogOpen(false)
|
|
resetForm()
|
|
} catch (error) {
|
|
console.error("[emprestimos] Falha ao criar", error)
|
|
toast.error("Falha ao criar empréstimo.")
|
|
} 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(`Empréstimo devolvido com multa de R$ ${result.multaCalculada.toFixed(2)}.`)
|
|
} else {
|
|
toast.success("Empréstimo devolvido com sucesso.")
|
|
}
|
|
setIsDevolverDialogOpen(false)
|
|
setSelectedEmprestimoId(null)
|
|
setFormObservacoes("")
|
|
} catch (error) {
|
|
console.error("[emprestimos] Falha ao devolver", error)
|
|
toast.error("Falha ao registrar devolução.")
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}, [selectedEmprestimoId, convexUserId, formObservacoes, devolverEmprestimo])
|
|
|
|
const openDevolverDialog = useCallback((id: string) => {
|
|
setSelectedEmprestimoId(id)
|
|
setFormObservacoes("")
|
|
setIsDevolverDialogOpen(true)
|
|
}, [])
|
|
|
|
const handleClearFilters = useCallback(() => {
|
|
setSearchQuery("")
|
|
setStatusFilter("all")
|
|
setClienteFilter(null)
|
|
setDateRange({ from: null, to: null })
|
|
}, [])
|
|
|
|
if (!tenantId || !convexUserId) {
|
|
return (
|
|
<div className="flex items-center justify-center py-20">
|
|
<Spinner className="size-8" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const fieldWrap = "min-w-[180px] flex-1"
|
|
const fieldTrigger =
|
|
"h-10 w-full rounded-2xl border border-slate-300 bg-slate-50/80 px-3 text-left text-sm font-semibold text-neutral-700 focus:ring-neutral-300 flex items-center gap-2"
|
|
const segmentedRoot =
|
|
"flex h-10 min-w-[200px] items-stretch rounded-full border border-slate-200 bg-slate-50/70 p-1 gap-1"
|
|
const segmentedItem =
|
|
"inline-flex h-full flex-1 items-center justify-center rounded-full px-4 text-sm font-semibold text-neutral-600 transition-colors hover:bg-slate-100 data-[state=on]:bg-cyan-600 data-[state=on]:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-300"
|
|
|
|
return (
|
|
<div className="space-y-6 px-4 lg:px-6">
|
|
{/* Stats Cards */}
|
|
<div className="grid gap-4 md:grid-cols-4">
|
|
<div className="rounded-2xl border border-slate-200 bg-white/90 p-4 shadow-sm transition-colors hover:border-cyan-200/60 hover:bg-cyan-50/30">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">Total</p>
|
|
<p className="mt-1 text-2xl font-bold text-neutral-900">{stats?.total ?? 0}</p>
|
|
</div>
|
|
<div className="rounded-2xl border border-cyan-200/60 bg-cyan-50/40 p-4 shadow-sm">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-cyan-600">Ativos</p>
|
|
<p className="mt-1 text-2xl font-bold text-cyan-700">{stats?.ativos ?? 0}</p>
|
|
</div>
|
|
<div className="rounded-2xl border border-red-200/60 bg-red-50/40 p-4 shadow-sm">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-red-600">Atrasados</p>
|
|
<p className="mt-1 text-2xl font-bold text-red-700">{stats?.atrasados ?? 0}</p>
|
|
</div>
|
|
<div className="rounded-2xl border border-emerald-200/60 bg-emerald-50/40 p-4 shadow-sm">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-emerald-600">Valor ativo</p>
|
|
<p className="mt-1 text-2xl font-bold text-emerald-700">
|
|
R$ {(stats?.valorTotalAtivo ?? 0).toLocaleString("pt-BR", { minimumFractionDigits: 2 })}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters Section */}
|
|
<section className="rounded-3xl border border-slate-200 bg-white/90 p-4 shadow-sm">
|
|
<div className="flex flex-col gap-4">
|
|
{/* Header with title and action */}
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-neutral-900">Empréstimos de Equipamentos</h2>
|
|
<p className="text-sm text-neutral-500">Gerencie o empréstimo de equipamentos para clientes.</p>
|
|
</div>
|
|
<Button
|
|
onClick={() => setIsCreateDialogOpen(true)}
|
|
className="h-10 gap-2 rounded-full bg-cyan-600 px-5 font-semibold text-white shadow-sm transition-colors hover:bg-cyan-700"
|
|
>
|
|
<IconPlus className="size-4" />
|
|
Novo empréstimo
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Search and Date Range */}
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:gap-4">
|
|
<div className="flex flex-1 flex-col gap-2">
|
|
<div className="relative">
|
|
<IconSearch className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-neutral-400" />
|
|
<Input
|
|
placeholder="Buscar por cliente, responsável, equipamento..."
|
|
value={searchQuery}
|
|
onChange={(event) => setSearchQuery(event.target.value)}
|
|
className="w-full rounded-2xl border-slate-300 bg-white/95 pl-9"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<DateRangeButton
|
|
from={dateRange.from}
|
|
to={dateRange.to}
|
|
onChange={setDateRange}
|
|
className="w-full min-w-[200px] rounded-2xl border-slate-300 bg-white/95 text-left text-sm font-semibold text-neutral-700 lg:w-auto"
|
|
align="center"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
className="h-10 gap-2 rounded-full text-sm font-medium text-neutral-700 hover:bg-slate-100"
|
|
onClick={handleClearFilters}
|
|
>
|
|
<IconRefresh className="size-4" />
|
|
Limpar
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Additional Filters */}
|
|
<div className="flex flex-wrap gap-3">
|
|
<div className={fieldWrap}>
|
|
<SearchableCombobox
|
|
value={clienteFilter}
|
|
onValueChange={setClienteFilter}
|
|
options={companyOptions}
|
|
placeholder="Cliente"
|
|
allowClear
|
|
clearLabel="Todos os clientes"
|
|
triggerClassName={fieldTrigger}
|
|
prefix={<IconBuilding className="size-4 text-neutral-400" />}
|
|
align="center"
|
|
/>
|
|
</div>
|
|
<ToggleGroup
|
|
type="single"
|
|
value={statusFilter}
|
|
onValueChange={(value) => value && setStatusFilter(value)}
|
|
className={segmentedRoot}
|
|
>
|
|
<ToggleGroupItem value="all" className={segmentedItem}>
|
|
Todos
|
|
</ToggleGroupItem>
|
|
<ToggleGroupItem value="ATIVO" className={segmentedItem}>
|
|
Ativos
|
|
</ToggleGroupItem>
|
|
<ToggleGroupItem value="DEVOLVIDO" className={segmentedItem}>
|
|
Devolvidos
|
|
</ToggleGroupItem>
|
|
</ToggleGroup>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Table Section */}
|
|
<section className="rounded-3xl border border-slate-200 bg-white/90 shadow-sm overflow-hidden">
|
|
{!emprestimos ? (
|
|
<div className="flex items-center justify-center py-16">
|
|
<Spinner className="size-8" />
|
|
</div>
|
|
) : filteredEmprestimos.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
<IconPackage className="mb-4 size-12 text-neutral-300" />
|
|
<p className="text-neutral-500">Nenhum empréstimo encontrado.</p>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-slate-50/80 hover:bg-slate-50/80">
|
|
<TableHead className="font-semibold text-neutral-600">Ref</TableHead>
|
|
<TableHead className="font-semibold text-neutral-600">Cliente</TableHead>
|
|
<TableHead className="font-semibold text-neutral-600">Responsável</TableHead>
|
|
<TableHead className="font-semibold text-neutral-600">Equipamentos</TableHead>
|
|
<TableHead className="font-semibold text-neutral-600">Data empréstimo</TableHead>
|
|
<TableHead className="font-semibold text-neutral-600">Data prevista</TableHead>
|
|
<TableHead className="font-semibold text-neutral-600">Status</TableHead>
|
|
<TableHead className="font-semibold text-neutral-600">Valor</TableHead>
|
|
<TableHead className="text-right font-semibold text-neutral-600">Ações</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredEmprestimos.map((emp) => (
|
|
<TableRow key={emp.id} className="transition-colors hover:bg-cyan-50/30">
|
|
<TableCell className="font-semibold text-cyan-700">#{emp.reference}</TableCell>
|
|
<TableCell className="font-medium">{emp.clienteNome}</TableCell>
|
|
<TableCell>{emp.responsavelNome}</TableCell>
|
|
<TableCell>
|
|
<span className="text-sm text-neutral-600">
|
|
{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"
|
|
className="h-8 gap-1.5 rounded-full bg-emerald-600 px-3 text-xs font-semibold text-white shadow-sm transition-colors hover:bg-emerald-700"
|
|
onClick={() => openDevolverDialog(emp.id)}
|
|
>
|
|
<IconCircleCheck className="size-3.5" />
|
|
Devolver
|
|
</Button>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Create Dialog */}
|
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Novo empréstimo</DialogTitle>
|
|
<DialogDescription>
|
|
Registre um novo empréstimo 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"
|
|
prefix={<IconBuilding className="size-4 text-neutral-400" />}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Técnico responsável *</label>
|
|
<SearchableCombobox
|
|
value={formTecnicoId}
|
|
onValueChange={setFormTecnicoId}
|
|
options={userOptions}
|
|
placeholder="Selecione o técnico"
|
|
prefix={<IconUser className="size-4 text-neutral-400" />}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Nome do responsável (cliente) *</label>
|
|
<Input
|
|
value={formResponsavelNome}
|
|
onChange={(e) => setFormResponsavelNome(e.target.value)}
|
|
placeholder="Nome do responsável"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Contato do responsável</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 empréstimo</label>
|
|
<DatePicker value={formDataEmprestimo} onChange={setFormDataEmprestimo} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Data prevista devolução *</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 diária 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}
|
|
className="h-8 gap-1.5 rounded-full"
|
|
>
|
|
<IconPlus className="size-3.5" />
|
|
Adicionar
|
|
</Button>
|
|
</div>
|
|
{formEquipamentos.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-slate-200 py-8">
|
|
<IconPackage className="mb-2 size-8 text-neutral-300" />
|
|
<p className="text-sm text-neutral-500">Nenhum equipamento adicionado.</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{formEquipamentos.map((eq, idx) => (
|
|
<div key={eq.id} className="rounded-xl border border-slate-200 bg-slate-50/50 p-3 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-semibold text-neutral-700">Equipamento {idx + 1}</span>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-7 gap-1 text-red-600 hover:bg-red-50 hover:text-red-700"
|
|
onClick={() => handleRemoveEquipamento(eq.id)}
|
|
>
|
|
<IconTrash className="size-3.5" />
|
|
Remover
|
|
</Button>
|
|
</div>
|
|
<div className="grid gap-2 md:grid-cols-4">
|
|
<Select
|
|
value={eq.tipo}
|
|
onValueChange={(v) => handleEquipamentoChange(eq.id, "tipo", v)}
|
|
>
|
|
<SelectTrigger className="h-9">
|
|
<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)}
|
|
className="h-9"
|
|
/>
|
|
<Input
|
|
placeholder="Modelo"
|
|
value={eq.modelo}
|
|
onChange={(e) => handleEquipamentoChange(eq.id, "modelo", e.target.value)}
|
|
className="h-9"
|
|
/>
|
|
<Input
|
|
placeholder="Serial/Patrimônio"
|
|
value={eq.serialNumber ?? ""}
|
|
onChange={(e) =>
|
|
handleEquipamentoChange(eq.id, "serialNumber", e.target.value)
|
|
}
|
|
className="h-9"
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Observações</label>
|
|
<Textarea
|
|
value={formObservacoes}
|
|
onChange={(e) => setFormObservacoes(e.target.value)}
|
|
placeholder="Observações sobre o empréstimo..."
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)} className="rounded-full">
|
|
Cancelar
|
|
</Button>
|
|
<Button
|
|
onClick={handleCreate}
|
|
disabled={isSubmitting}
|
|
className="gap-2 rounded-full bg-cyan-600 font-semibold text-white hover:bg-cyan-700"
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<Spinner className="size-4" />
|
|
Criando...
|
|
</>
|
|
) : (
|
|
"Criar empréstimo"
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Devolver Dialog */}
|
|
<Dialog open={isDevolverDialogOpen} onOpenChange={setIsDevolverDialogOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Registrar devolução</DialogTitle>
|
|
<DialogDescription>
|
|
Confirme a devolução dos equipamentos emprestados.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Observações da devolução</label>
|
|
<Textarea
|
|
value={formObservacoes}
|
|
onChange={(e) => setFormObservacoes(e.target.value)}
|
|
placeholder="Condição dos equipamentos, observações..."
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setIsDevolverDialogOpen(false)} className="rounded-full">
|
|
Cancelar
|
|
</Button>
|
|
<Button
|
|
onClick={handleDevolver}
|
|
disabled={isSubmitting}
|
|
className="gap-2 rounded-full bg-emerald-600 font-semibold text-white hover:bg-emerald-700"
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<Spinner className="size-4" />
|
|
Registrando...
|
|
</>
|
|
) : (
|
|
<>
|
|
<IconCircleCheck className="size-4" />
|
|
Confirmar devolução
|
|
</>
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|