Improve loan page and add company filter to USB bulk control
- 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>
This commit is contained in:
parent
063c5dfde7
commit
38995b95c6
8 changed files with 1123 additions and 1184 deletions
|
|
@ -22,9 +22,10 @@
|
|||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"png-to-ico": "^3.0.1",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"baseline-browser-mapping": "^2.9.2",
|
||||
"png-to-ico": "^3.0.1",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
|
|
|
|||
1
apps/desktop/src-tauri/Cargo.lock
generated
1
apps/desktop/src-tauri/Cargo.lock
generated
|
|
@ -79,6 +79,7 @@ dependencies = [
|
|||
"tauri-plugin-updater",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -916,7 +916,7 @@ const resolvedAppUrl = useMemo(() => {
|
|||
syncRemoteAccessNow(rustdeskInfo)
|
||||
}
|
||||
}
|
||||
}, [store, rustdeskInfo, ensureRustdesk, syncRemoteAccessNow, isRustdeskProvisioning])
|
||||
}, [store, config?.machineId, rustdeskInfo, ensureRustdesk, syncRemoteAccessNow, isRustdeskProvisioning])
|
||||
|
||||
async function register() {
|
||||
if (!profile) return
|
||||
|
|
|
|||
18
package.json
18
package.json
|
|
@ -32,8 +32,8 @@
|
|||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@noble/hashes": "^1.5.0",
|
||||
"@prisma/adapter-better-sqlite3": "^7.0.0",
|
||||
"@paper-design/shaders-react": "^0.0.55",
|
||||
"@prisma/adapter-better-sqlite3": "^7.0.0",
|
||||
"@prisma/client": "^7.0.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
|
|
@ -69,13 +69,13 @@
|
|||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next": "^16.0.3",
|
||||
"next": "^16.0.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"pdfkit": "^0.17.2",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "19.2.0",
|
||||
"react": "^19.2.1",
|
||||
"react-day-picker": "^9.4.2",
|
||||
"react-dom": "19.2.0",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-hook-form": "^7.64.0",
|
||||
"recharts": "^2.15.4",
|
||||
"sanitize-html": "^2.17.0",
|
||||
|
|
@ -101,24 +101,28 @@
|
|||
"@types/sanitize-html": "^2.16.0",
|
||||
"@types/three": "^0.180.0",
|
||||
"@vitest/browser-playwright": "^4.0.1",
|
||||
"baseline-browser-mapping": "^2.9.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "^16.0.3",
|
||||
"eslint-config-next": "^16.0.7",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"jsdom": "^27.0.1",
|
||||
"playwright": "^1.56.1",
|
||||
"prisma": "^7.0.0",
|
||||
"tailwindcss": "^4",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.19.1",
|
||||
"tw-animate-css": "^1.3.8",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.46.2",
|
||||
"tsx": "^4.19.1",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^4.0.1"
|
||||
},
|
||||
"workspaces": [
|
||||
".",
|
||||
"apps/desktop"
|
||||
]
|
||||
],
|
||||
"overrides": {
|
||||
"baseline-browser-mapping": "^2.9.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,19 +2,21 @@
|
|||
|
||||
import { useState, useMemo, useCallback } from "react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
import { format, formatDistanceToNow, isAfter } from "date-fns"
|
||||
import { format } 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"
|
||||
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"
|
||||
|
|
@ -22,7 +24,6 @@ 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,
|
||||
|
|
@ -48,9 +49,10 @@ import {
|
|||
} 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 { cn } from "@/lib/utils"
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||
|
||||
const EQUIPAMENTO_TIPOS = [
|
||||
"NOTEBOOK",
|
||||
|
|
@ -70,8 +72,6 @@ const EQUIPAMENTO_TIPOS = [
|
|||
"OUTRO",
|
||||
] as const
|
||||
|
||||
type EmprestimoStatus = "ATIVO" | "DEVOLVIDO" | "ATRASADO" | "CANCELADO"
|
||||
|
||||
type Equipamento = {
|
||||
id: string
|
||||
tipo: string
|
||||
|
|
@ -109,8 +109,8 @@ function getStatusBadge(status: string, dataFimPrevisto: number) {
|
|||
|
||||
if (isAtrasado) {
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 border-red-200 bg-red-50 text-red-700">
|
||||
<AlertTriangle className="size-3" />
|
||||
<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>
|
||||
)
|
||||
|
|
@ -119,21 +119,21 @@ function getStatusBadge(status: string, dataFimPrevisto: number) {
|
|||
switch (status) {
|
||||
case "ATIVO":
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 border-blue-200 bg-blue-50 text-blue-700">
|
||||
<Clock className="size-3" />
|
||||
<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 variant="outline" className="gap-1 border-emerald-200 bg-emerald-50 text-emerald-700">
|
||||
<CheckCircle2 className="size-3" />
|
||||
<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 variant="outline" className="gap-1 border-neutral-200 bg-neutral-50 text-neutral-600">
|
||||
<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>
|
||||
)
|
||||
|
|
@ -142,12 +142,15 @@ function getStatusBadge(status: string, dataFimPrevisto: number) {
|
|||
}
|
||||
}
|
||||
|
||||
const ALL_VALUE = "ALL"
|
||||
|
||||
export function EmprestimosPageClient() {
|
||||
const { session, convexUserId, role } = useAuth()
|
||||
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)
|
||||
|
|
@ -224,23 +227,37 @@ export function EmprestimosPageClient() {
|
|||
|
||||
const filteredEmprestimos = useMemo<EmprestimoListItem[]>(() => {
|
||||
if (!emprestimos) return []
|
||||
const list = emprestimos as EmprestimoListItem[]
|
||||
const q = searchQuery.toLowerCase().trim()
|
||||
if (!q) return list
|
||||
let list = emprestimos as EmprestimoListItem[]
|
||||
|
||||
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])
|
||||
// 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) => [
|
||||
|
|
@ -283,11 +300,11 @@ export function EmprestimosPageClient() {
|
|||
const handleCreate = useCallback(async () => {
|
||||
if (!tenantId || !convexUserId) return
|
||||
if (!formClienteId || !formTecnicoId || !formDataFim || formEquipamentos.length === 0) {
|
||||
toast.error("Preencha todos os campos obrigatorios.")
|
||||
toast.error("Preencha todos os campos obrigatórios.")
|
||||
return
|
||||
}
|
||||
if (!formResponsavelNome.trim()) {
|
||||
toast.error("Informe o nome do responsavel.")
|
||||
toast.error("Informe o nome do responsável.")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -307,12 +324,12 @@ export function EmprestimosPageClient() {
|
|||
observacoes: formObservacoes || undefined,
|
||||
multaDiaria: formMultaDiaria ? parseFloat(formMultaDiaria) : undefined,
|
||||
})
|
||||
toast.success(`Emprestimo #${result.reference} criado com sucesso.`)
|
||||
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 emprestimo.")
|
||||
toast.error("Falha ao criar empréstimo.")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
|
@ -344,16 +361,16 @@ export function EmprestimosPageClient() {
|
|||
observacoes: formObservacoes || undefined,
|
||||
})
|
||||
if (result.multaCalculada) {
|
||||
toast.success(`Emprestimo devolvido com multa de R$ ${result.multaCalculada.toFixed(2)}.`)
|
||||
toast.success(`Empréstimo devolvido com multa de R$ ${result.multaCalculada.toFixed(2)}.`)
|
||||
} else {
|
||||
toast.success("Emprestimo devolvido com sucesso.")
|
||||
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 devolucao.")
|
||||
toast.error("Falha ao registrar devolução.")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
|
@ -365,6 +382,13 @@ export function EmprestimosPageClient() {
|
|||
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">
|
||||
|
|
@ -373,173 +397,205 @@ export function EmprestimosPageClient() {
|
|||
)
|
||||
}
|
||||
|
||||
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">
|
||||
{/* Stats */}
|
||||
<div className="space-y-6 px-4 lg:px-6">
|
||||
{/* Stats Cards */}
|
||||
<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 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 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"
|
||||
/>
|
||||
{/* 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>
|
||||
<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)
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<RotateCcw className="mr-2 size-4" />
|
||||
Limpar
|
||||
<IconPlus className="size-4" />
|
||||
Novo empréstimo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{!emprestimos ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Spinner className="size-8" />
|
||||
{/* 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>
|
||||
) : 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 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 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>
|
||||
</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>
|
||||
</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>
|
||||
))}
|
||||
</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 emprestimo</DialogTitle>
|
||||
<DialogTitle>Novo empréstimo</DialogTitle>
|
||||
<DialogDescription>
|
||||
Registre um novo emprestimo de equipamentos.
|
||||
Registre um novo empréstimo de equipamentos.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -552,30 +608,32 @@ export function EmprestimosPageClient() {
|
|||
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">Tecnico responsavel *</label>
|
||||
<label className="text-sm font-medium">Técnico responsável *</label>
|
||||
<SearchableCombobox
|
||||
value={formTecnicoId}
|
||||
onValueChange={setFormTecnicoId}
|
||||
options={userOptions}
|
||||
placeholder="Selecione o tecnico"
|
||||
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 responsavel (cliente) *</label>
|
||||
<label className="text-sm font-medium">Nome do responsável (cliente) *</label>
|
||||
<Input
|
||||
value={formResponsavelNome}
|
||||
onChange={(e) => setFormResponsavelNome(e.target.value)}
|
||||
placeholder="Nome do responsavel"
|
||||
placeholder="Nome do responsável"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Contato do responsavel</label>
|
||||
<label className="text-sm font-medium">Contato do responsável</label>
|
||||
<Input
|
||||
value={formResponsavelContato}
|
||||
onChange={(e) => setFormResponsavelContato(e.target.value)}
|
||||
|
|
@ -586,11 +644,11 @@ export function EmprestimosPageClient() {
|
|||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Data do emprestimo</label>
|
||||
<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 devolucao *</label>
|
||||
<label className="text-sm font-medium">Data prevista devolução *</label>
|
||||
<DatePicker value={formDataFim} onChange={setFormDataFim} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -607,7 +665,7 @@ export function EmprestimosPageClient() {
|
|||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Multa diaria por atraso (R$)</label>
|
||||
<label className="text-sm font-medium">Multa diária por atraso (R$)</label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
|
|
@ -621,28 +679,36 @@ export function EmprestimosPageClient() {
|
|||
<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" />
|
||||
<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 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
Nenhum equipamento adicionado.
|
||||
</p>
|
||||
<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-md border p-3 space-y-2">
|
||||
<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-medium">Equipamento {idx + 1}</span>
|
||||
<span className="text-sm font-semibold text-neutral-700">Equipamento {idx + 1}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-600"
|
||||
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>
|
||||
|
|
@ -651,7 +717,7 @@ export function EmprestimosPageClient() {
|
|||
value={eq.tipo}
|
||||
onValueChange={(v) => handleEquipamentoChange(eq.id, "tipo", v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -666,18 +732,21 @@ export function EmprestimosPageClient() {
|
|||
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/Patrimonio"
|
||||
placeholder="Serial/Patrimônio"
|
||||
value={eq.serialNumber ?? ""}
|
||||
onChange={(e) =>
|
||||
handleEquipamentoChange(eq.id, "serialNumber", e.target.value)
|
||||
}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -687,28 +756,32 @@ export function EmprestimosPageClient() {
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Observacoes</label>
|
||||
<label className="text-sm font-medium">Observações</label>
|
||||
<Textarea
|
||||
value={formObservacoes}
|
||||
onChange={(e) => setFormObservacoes(e.target.value)}
|
||||
placeholder="Observacoes sobre o emprestimo..."
|
||||
placeholder="Observações sobre o empréstimo..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
|
||||
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)} className="rounded-full">
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={isSubmitting}>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={isSubmitting}
|
||||
className="gap-2 rounded-full bg-cyan-600 font-semibold text-white hover:bg-cyan-700"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Spinner className="mr-2 size-4" />
|
||||
<Spinner className="size-4" />
|
||||
Criando...
|
||||
</>
|
||||
) : (
|
||||
"Criar emprestimo"
|
||||
"Criar empréstimo"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
@ -719,38 +792,42 @@ export function EmprestimosPageClient() {
|
|||
<Dialog open={isDevolverDialogOpen} onOpenChange={setIsDevolverDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Registrar devolucao</DialogTitle>
|
||||
<DialogTitle>Registrar devolução</DialogTitle>
|
||||
<DialogDescription>
|
||||
Confirme a devolucao dos equipamentos emprestados.
|
||||
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">Observacoes da devolucao</label>
|
||||
<label className="text-sm font-medium">Observações da devolução</label>
|
||||
<Textarea
|
||||
value={formObservacoes}
|
||||
onChange={(e) => setFormObservacoes(e.target.value)}
|
||||
placeholder="Condicao dos equipamentos, observacoes..."
|
||||
placeholder="Condição dos equipamentos, observações..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsDevolverDialogOpen(false)}>
|
||||
<Button variant="outline" onClick={() => setIsDevolverDialogOpen(false)} className="rounded-full">
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleDevolver} disabled={isSubmitting}>
|
||||
<Button
|
||||
onClick={handleDevolver}
|
||||
disabled={isSubmitting}
|
||||
className="gap-2 rounded-full bg-emerald-600 font-semibold text-white hover:bg-emerald-700"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Spinner className="mr-2 size-4" />
|
||||
<Spinner className="size-4" />
|
||||
Registrando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="mr-2 size-4" />
|
||||
Confirmar devolucao
|
||||
<IconCircleCheck className="size-4" />
|
||||
Confirmar devolução
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1269,6 +1269,7 @@ export function AdminDevicesOverview({
|
|||
const [selectedUsbPolicy, setSelectedUsbPolicy] = useState<"ALLOW" | "BLOCK_ALL" | "READONLY">("ALLOW")
|
||||
const [isApplyingUsbPolicy, setIsApplyingUsbPolicy] = useState(false)
|
||||
const [usbPolicySelection, setUsbPolicySelection] = useState<string[]>([])
|
||||
const [usbCompanyFilter, setUsbCompanyFilter] = useState<string>("all")
|
||||
const [isCreateDeviceOpen, setIsCreateDeviceOpen] = useState(false)
|
||||
const [createDeviceLoading, setCreateDeviceLoading] = useState(false)
|
||||
const [newDeviceName, setNewDeviceName] = useState("")
|
||||
|
|
@ -1713,15 +1714,18 @@ export function AdminDevicesOverview({
|
|||
}, [])
|
||||
|
||||
const handleSelectAllUsbDevices = useCallback((checked: boolean) => {
|
||||
const windowsDevices = filteredDevices.filter(
|
||||
const allWindowsDevices = filteredDevices.filter(
|
||||
(m) => (m.devicePlatform ?? "").toLowerCase() === "windows"
|
||||
)
|
||||
const windowsDevices = usbCompanyFilter === "all"
|
||||
? allWindowsDevices
|
||||
: allWindowsDevices.filter((m) => (m.companySlug ?? "") === usbCompanyFilter)
|
||||
if (checked) {
|
||||
setUsbPolicySelection(windowsDevices.map((m) => m.id))
|
||||
} else {
|
||||
setUsbPolicySelection([])
|
||||
}
|
||||
}, [filteredDevices])
|
||||
}, [filteredDevices, usbCompanyFilter])
|
||||
|
||||
const handleApplyBulkUsbPolicy = useCallback(async () => {
|
||||
if (usbPolicySelection.length === 0) {
|
||||
|
|
@ -1853,7 +1857,7 @@ export function AdminDevicesOverview({
|
|||
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<CardTitle>Dispositivos registrados</CardTitle>
|
||||
<CardDescription>Sincronizadas via agente local instalado nas máquinas. Atualiza em tempo quase real.</CardDescription>
|
||||
<CardDescription>Sincronizadas via agente local instalado nas máquinas.</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" onClick={handleOpenCreateDevice}>
|
||||
|
|
@ -2199,20 +2203,55 @@ export function AdminDevicesOverview({
|
|||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{(() => {
|
||||
const windowsDevices = filteredDevices.filter(
|
||||
const allWindowsDevices = filteredDevices.filter(
|
||||
(m) => (m.devicePlatform ?? "").toLowerCase() === "windows"
|
||||
)
|
||||
if (windowsDevices.length === 0) {
|
||||
const windowsDevices = usbCompanyFilter === "all"
|
||||
? allWindowsDevices
|
||||
: allWindowsDevices.filter((m) => (m.companySlug ?? "") === usbCompanyFilter)
|
||||
const usbCompanyOptions = Array.from(
|
||||
new Map(
|
||||
allWindowsDevices
|
||||
.filter((m) => m.companySlug)
|
||||
.map((m) => [m.companySlug, m.companyName ?? m.companySlug])
|
||||
)
|
||||
).sort((a, b) => (a[1] ?? "").localeCompare(b[1] ?? "", "pt-BR"))
|
||||
if (allWindowsDevices.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-slate-200 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Nenhum dispositivo Windows disponível com os filtros atuais.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const allSelected = usbPolicySelection.length === windowsDevices.length
|
||||
const allSelected = windowsDevices.length > 0 && usbPolicySelection.length === windowsDevices.length
|
||||
const someSelected = usbPolicySelection.length > 0 && usbPolicySelection.length < windowsDevices.length
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-700">Filtrar por empresa</label>
|
||||
<Select
|
||||
value={usbCompanyFilter}
|
||||
onValueChange={(value) => {
|
||||
setUsbCompanyFilter(value)
|
||||
setUsbPolicySelection([])
|
||||
}}
|
||||
disabled={isApplyingUsbPolicy}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione uma empresa" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
<span>Todas as empresas</span>
|
||||
</SelectItem>
|
||||
{usbCompanyOptions.map(([slug, name]) => (
|
||||
<SelectItem key={slug} value={slug ?? ""}>
|
||||
<span>{name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600">
|
||||
<span>
|
||||
{usbPolicySelection.length} de {windowsDevices.length} dispositivos Windows selecionados
|
||||
|
|
@ -2220,9 +2259,14 @@ export function AdminDevicesOverview({
|
|||
<Checkbox
|
||||
checked={allSelected ? true : someSelected ? "indeterminate" : false}
|
||||
onCheckedChange={(value) => handleSelectAllUsbDevices(value === true || value === "indeterminate")}
|
||||
disabled={isApplyingUsbPolicy}
|
||||
disabled={isApplyingUsbPolicy || windowsDevices.length === 0}
|
||||
/>
|
||||
</div>
|
||||
{windowsDevices.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-slate-200 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
Nenhum dispositivo Windows nesta empresa.
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto rounded-md border border-slate-200 p-2">
|
||||
{windowsDevices.map((device) => {
|
||||
const checked = usbPolicySelection.includes(device.id)
|
||||
|
|
@ -2249,6 +2293,7 @@ export function AdminDevicesOverview({
|
|||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-700">Política USB</label>
|
||||
<Select
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ const navigation: NavigationGroup[] = [
|
|||
{ title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" },
|
||||
{ title: "Agenda", url: "/agenda", icon: CalendarDays, requiredRole: "staff" },
|
||||
{ title: "Dispositivos", url: "/admin/devices", icon: MonitorCog, requiredRole: "admin" },
|
||||
{ title: "Emprestimos", url: "/emprestimos", icon: Package, requiredRole: "staff" },
|
||||
{ title: "Empréstimos", url: "/emprestimos", icon: Package, requiredRole: "staff" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue