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"
|
"react-dom": "^19.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"png-to-ico": "^3.0.1",
|
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"baseline-browser-mapping": "^2.9.2",
|
||||||
|
"png-to-ico": "^3.0.1",
|
||||||
"typescript": "~5.6.2",
|
"typescript": "~5.6.2",
|
||||||
"vite": "^6.0.3"
|
"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",
|
"tauri-plugin-updater",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"winreg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -916,7 +916,7 @@ const resolvedAppUrl = useMemo(() => {
|
||||||
syncRemoteAccessNow(rustdeskInfo)
|
syncRemoteAccessNow(rustdeskInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [store, rustdeskInfo, ensureRustdesk, syncRemoteAccessNow, isRustdeskProvisioning])
|
}, [store, config?.machineId, rustdeskInfo, ensureRustdesk, syncRemoteAccessNow, isRustdeskProvisioning])
|
||||||
|
|
||||||
async function register() {
|
async function register() {
|
||||||
if (!profile) return
|
if (!profile) return
|
||||||
|
|
|
||||||
18
package.json
18
package.json
|
|
@ -32,8 +32,8 @@
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@noble/hashes": "^1.5.0",
|
"@noble/hashes": "^1.5.0",
|
||||||
"@prisma/adapter-better-sqlite3": "^7.0.0",
|
|
||||||
"@paper-design/shaders-react": "^0.0.55",
|
"@paper-design/shaders-react": "^0.0.55",
|
||||||
|
"@prisma/adapter-better-sqlite3": "^7.0.0",
|
||||||
"@prisma/client": "^7.0.0",
|
"@prisma/client": "^7.0.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
|
|
@ -69,13 +69,13 @@
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next": "^16.0.3",
|
"next": "^16.0.7",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "19.2.0",
|
"react": "^19.2.1",
|
||||||
"react-day-picker": "^9.4.2",
|
"react-day-picker": "^9.4.2",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "^19.2.1",
|
||||||
"react-hook-form": "^7.64.0",
|
"react-hook-form": "^7.64.0",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
|
|
@ -101,24 +101,28 @@
|
||||||
"@types/sanitize-html": "^2.16.0",
|
"@types/sanitize-html": "^2.16.0",
|
||||||
"@types/three": "^0.180.0",
|
"@types/three": "^0.180.0",
|
||||||
"@vitest/browser-playwright": "^4.0.1",
|
"@vitest/browser-playwright": "^4.0.1",
|
||||||
|
"baseline-browser-mapping": "^2.9.2",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "^16.0.3",
|
"eslint-config-next": "^16.0.7",
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
"jsdom": "^27.0.1",
|
"jsdom": "^27.0.1",
|
||||||
"playwright": "^1.56.1",
|
"playwright": "^1.56.1",
|
||||||
"prisma": "^7.0.0",
|
"prisma": "^7.0.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"tsx": "^4.19.1",
|
||||||
"tw-animate-css": "^1.3.8",
|
"tw-animate-css": "^1.3.8",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"typescript-eslint": "^8.46.2",
|
"typescript-eslint": "^8.46.2",
|
||||||
"tsx": "^4.19.1",
|
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
"vitest": "^4.0.1"
|
"vitest": "^4.0.1"
|
||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
".",
|
".",
|
||||||
"apps/desktop"
|
"apps/desktop"
|
||||||
]
|
],
|
||||||
|
"overrides": {
|
||||||
|
"baseline-browser-mapping": "^2.9.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,21 @@
|
||||||
|
|
||||||
import { useState, useMemo, useCallback } from "react"
|
import { useState, useMemo, useCallback } from "react"
|
||||||
import { useMutation, useQuery } from "convex/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 { ptBR } from "date-fns/locale"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import {
|
import {
|
||||||
Plus,
|
IconPlus,
|
||||||
Package,
|
IconPackage,
|
||||||
CheckCircle2,
|
IconCircleCheck,
|
||||||
Clock,
|
IconClock,
|
||||||
AlertTriangle,
|
IconAlertTriangle,
|
||||||
Search,
|
IconSearch,
|
||||||
ChevronDown,
|
IconRefresh,
|
||||||
RotateCcw,
|
IconBuilding,
|
||||||
} from "lucide-react"
|
IconUser,
|
||||||
|
IconTrash,
|
||||||
|
} from "@tabler/icons-react"
|
||||||
|
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import type { Id } from "@/convex/_generated/dataModel"
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
|
@ -22,7 +24,6 @@ import { useAuth } from "@/lib/auth-client"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -48,9 +49,10 @@ import {
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import { Spinner } from "@/components/ui/spinner"
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
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 { DatePicker } from "@/components/ui/date-picker"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { cn } from "@/lib/utils"
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||||
|
|
||||||
const EQUIPAMENTO_TIPOS = [
|
const EQUIPAMENTO_TIPOS = [
|
||||||
"NOTEBOOK",
|
"NOTEBOOK",
|
||||||
|
|
@ -70,8 +72,6 @@ const EQUIPAMENTO_TIPOS = [
|
||||||
"OUTRO",
|
"OUTRO",
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
type EmprestimoStatus = "ATIVO" | "DEVOLVIDO" | "ATRASADO" | "CANCELADO"
|
|
||||||
|
|
||||||
type Equipamento = {
|
type Equipamento = {
|
||||||
id: string
|
id: string
|
||||||
tipo: string
|
tipo: string
|
||||||
|
|
@ -109,8 +109,8 @@ function getStatusBadge(status: string, dataFimPrevisto: number) {
|
||||||
|
|
||||||
if (isAtrasado) {
|
if (isAtrasado) {
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className="gap-1 border-red-200 bg-red-50 text-red-700">
|
<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">
|
||||||
<AlertTriangle className="size-3" />
|
<IconAlertTriangle className="size-3" />
|
||||||
Atrasado
|
Atrasado
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
|
|
@ -119,21 +119,21 @@ function getStatusBadge(status: string, dataFimPrevisto: number) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "ATIVO":
|
case "ATIVO":
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className="gap-1 border-blue-200 bg-blue-50 text-blue-700">
|
<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">
|
||||||
<Clock className="size-3" />
|
<IconClock className="size-3" />
|
||||||
Ativo
|
Ativo
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
case "DEVOLVIDO":
|
case "DEVOLVIDO":
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className="gap-1 border-emerald-200 bg-emerald-50 text-emerald-700">
|
<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">
|
||||||
<CheckCircle2 className="size-3" />
|
<IconCircleCheck className="size-3" />
|
||||||
Devolvido
|
Devolvido
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
case "CANCELADO":
|
case "CANCELADO":
|
||||||
return (
|
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
|
Cancelado
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
|
|
@ -142,12 +142,15 @@ function getStatusBadge(status: string, dataFimPrevisto: number) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ALL_VALUE = "ALL"
|
||||||
|
|
||||||
export function EmprestimosPageClient() {
|
export function EmprestimosPageClient() {
|
||||||
const { session, convexUserId, role } = useAuth()
|
const { session, convexUserId } = useAuth()
|
||||||
const tenantId = session?.user?.tenantId ?? null
|
const tenantId = session?.user?.tenantId ?? null
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all")
|
const [statusFilter, setStatusFilter] = useState<string>("all")
|
||||||
const [clienteFilter, setClienteFilter] = useState<string | null>(null)
|
const [clienteFilter, setClienteFilter] = useState<string | null>(null)
|
||||||
|
const [dateRange, setDateRange] = useState<DateRangeValue>({ from: null, to: null })
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||||
const [isDevolverDialogOpen, setIsDevolverDialogOpen] = useState(false)
|
const [isDevolverDialogOpen, setIsDevolverDialogOpen] = useState(false)
|
||||||
const [selectedEmprestimoId, setSelectedEmprestimoId] = useState<string | null>(null)
|
const [selectedEmprestimoId, setSelectedEmprestimoId] = useState<string | null>(null)
|
||||||
|
|
@ -224,11 +227,12 @@ export function EmprestimosPageClient() {
|
||||||
|
|
||||||
const filteredEmprestimos = useMemo<EmprestimoListItem[]>(() => {
|
const filteredEmprestimos = useMemo<EmprestimoListItem[]>(() => {
|
||||||
if (!emprestimos) return []
|
if (!emprestimos) return []
|
||||||
const list = emprestimos as EmprestimoListItem[]
|
let list = emprestimos as EmprestimoListItem[]
|
||||||
const q = searchQuery.toLowerCase().trim()
|
|
||||||
if (!q) return list
|
|
||||||
|
|
||||||
return list.filter((e) => {
|
// Filtro por busca
|
||||||
|
const q = searchQuery.toLowerCase().trim()
|
||||||
|
if (q) {
|
||||||
|
list = list.filter((e) => {
|
||||||
const searchFields = [
|
const searchFields = [
|
||||||
e.clienteNome,
|
e.clienteNome,
|
||||||
e.responsavelNome,
|
e.responsavelNome,
|
||||||
|
|
@ -240,7 +244,20 @@ export function EmprestimosPageClient() {
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
return searchFields.includes(q)
|
return searchFields.includes(q)
|
||||||
})
|
})
|
||||||
}, [emprestimos, searchQuery])
|
}
|
||||||
|
|
||||||
|
// 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(() => {
|
const handleAddEquipamento = useCallback(() => {
|
||||||
setFormEquipamentos((prev) => [
|
setFormEquipamentos((prev) => [
|
||||||
|
|
@ -283,11 +300,11 @@ export function EmprestimosPageClient() {
|
||||||
const handleCreate = useCallback(async () => {
|
const handleCreate = useCallback(async () => {
|
||||||
if (!tenantId || !convexUserId) return
|
if (!tenantId || !convexUserId) return
|
||||||
if (!formClienteId || !formTecnicoId || !formDataFim || formEquipamentos.length === 0) {
|
if (!formClienteId || !formTecnicoId || !formDataFim || formEquipamentos.length === 0) {
|
||||||
toast.error("Preencha todos os campos obrigatorios.")
|
toast.error("Preencha todos os campos obrigatórios.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!formResponsavelNome.trim()) {
|
if (!formResponsavelNome.trim()) {
|
||||||
toast.error("Informe o nome do responsavel.")
|
toast.error("Informe o nome do responsável.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,12 +324,12 @@ export function EmprestimosPageClient() {
|
||||||
observacoes: formObservacoes || undefined,
|
observacoes: formObservacoes || undefined,
|
||||||
multaDiaria: formMultaDiaria ? parseFloat(formMultaDiaria) : 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)
|
setIsCreateDialogOpen(false)
|
||||||
resetForm()
|
resetForm()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[emprestimos] Falha ao criar", error)
|
console.error("[emprestimos] Falha ao criar", error)
|
||||||
toast.error("Falha ao criar emprestimo.")
|
toast.error("Falha ao criar empréstimo.")
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
|
|
@ -344,16 +361,16 @@ export function EmprestimosPageClient() {
|
||||||
observacoes: formObservacoes || undefined,
|
observacoes: formObservacoes || undefined,
|
||||||
})
|
})
|
||||||
if (result.multaCalculada) {
|
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 {
|
} else {
|
||||||
toast.success("Emprestimo devolvido com sucesso.")
|
toast.success("Empréstimo devolvido com sucesso.")
|
||||||
}
|
}
|
||||||
setIsDevolverDialogOpen(false)
|
setIsDevolverDialogOpen(false)
|
||||||
setSelectedEmprestimoId(null)
|
setSelectedEmprestimoId(null)
|
||||||
setFormObservacoes("")
|
setFormObservacoes("")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[emprestimos] Falha ao devolver", error)
|
console.error("[emprestimos] Falha ao devolver", error)
|
||||||
toast.error("Falha ao registrar devolucao.")
|
toast.error("Falha ao registrar devolução.")
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
|
|
@ -365,6 +382,13 @@ export function EmprestimosPageClient() {
|
||||||
setIsDevolverDialogOpen(true)
|
setIsDevolverDialogOpen(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleClearFilters = useCallback(() => {
|
||||||
|
setSearchQuery("")
|
||||||
|
setStatusFilter("all")
|
||||||
|
setClienteFilter(null)
|
||||||
|
setDateRange({ from: null, to: null })
|
||||||
|
}, [])
|
||||||
|
|
||||||
if (!tenantId || !convexUserId) {
|
if (!tenantId || !convexUserId) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
|
|
@ -373,125 +397,158 @@ 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 px-4 lg:px-6">
|
||||||
{/* Stats */}
|
{/* Stats Cards */}
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
<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">
|
||||||
<CardHeader className="pb-2">
|
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">Total</p>
|
||||||
<CardDescription>Total</CardDescription>
|
<p className="mt-1 text-2xl font-bold text-neutral-900">{stats?.total ?? 0}</p>
|
||||||
<CardTitle className="text-2xl">{stats?.total ?? 0}</CardTitle>
|
</div>
|
||||||
</CardHeader>
|
<div className="rounded-2xl border border-cyan-200/60 bg-cyan-50/40 p-4 shadow-sm">
|
||||||
</Card>
|
<p className="text-xs font-medium uppercase tracking-wide text-cyan-600">Ativos</p>
|
||||||
<Card>
|
<p className="mt-1 text-2xl font-bold text-cyan-700">{stats?.ativos ?? 0}</p>
|
||||||
<CardHeader className="pb-2">
|
</div>
|
||||||
<CardDescription>Ativos</CardDescription>
|
<div className="rounded-2xl border border-red-200/60 bg-red-50/40 p-4 shadow-sm">
|
||||||
<CardTitle className="text-2xl text-blue-600">{stats?.ativos ?? 0}</CardTitle>
|
<p className="text-xs font-medium uppercase tracking-wide text-red-600">Atrasados</p>
|
||||||
</CardHeader>
|
<p className="mt-1 text-2xl font-bold text-red-700">{stats?.atrasados ?? 0}</p>
|
||||||
</Card>
|
</div>
|
||||||
<Card>
|
<div className="rounded-2xl border border-emerald-200/60 bg-emerald-50/40 p-4 shadow-sm">
|
||||||
<CardHeader className="pb-2">
|
<p className="text-xs font-medium uppercase tracking-wide text-emerald-600">Valor ativo</p>
|
||||||
<CardDescription>Atrasados</CardDescription>
|
<p className="mt-1 text-2xl font-bold text-emerald-700">
|
||||||
<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 })}
|
R$ {(stats?.valorTotalAtivo ?? 0).toLocaleString("pt-BR", { minimumFractionDigits: 2 })}
|
||||||
</CardTitle>
|
</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters and Actions */}
|
{/* Filters Section */}
|
||||||
<Card>
|
<section className="rounded-3xl border border-slate-200 bg-white/90 p-4 shadow-sm">
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
<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>
|
<div>
|
||||||
<CardTitle>Emprestimos</CardTitle>
|
<h2 className="text-lg font-semibold text-neutral-900">Empréstimos de Equipamentos</h2>
|
||||||
<CardDescription>Gerencie o emprestimo de equipamentos para clientes.</CardDescription>
|
<p className="text-sm text-neutral-500">Gerencie o empréstimo de equipamentos para clientes.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
<Button
|
||||||
<Plus className="mr-2 size-4" />
|
onClick={() => setIsCreateDialogOpen(true)}
|
||||||
Novo emprestimo
|
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>
|
</Button>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
|
||||||
<div className="mb-4 flex flex-wrap gap-3">
|
{/* Search and Date Range */}
|
||||||
<div className="relative flex-1">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:gap-4">
|
||||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
<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
|
<Input
|
||||||
|
placeholder="Buscar por cliente, responsável, equipamento..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(event) => setSearchQuery(event.target.value)}
|
||||||
placeholder="Buscar por cliente, responsavel, equipamento..."
|
className="w-full rounded-2xl border-slate-300 bg-white/95 pl-9"
|
||||||
className="pl-9"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
</div>
|
||||||
<SelectTrigger className="w-[180px]">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<SelectValue placeholder="Status" />
|
<DateRangeButton
|
||||||
</SelectTrigger>
|
from={dateRange.from}
|
||||||
<SelectContent>
|
to={dateRange.to}
|
||||||
<SelectItem value="all">Todos status</SelectItem>
|
onChange={setDateRange}
|
||||||
<SelectItem value="ATIVO">Ativo</SelectItem>
|
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"
|
||||||
<SelectItem value="DEVOLVIDO">Devolvido</SelectItem>
|
align="center"
|
||||||
</SelectContent>
|
/>
|
||||||
</Select>
|
<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
|
<SearchableCombobox
|
||||||
value={clienteFilter}
|
value={clienteFilter}
|
||||||
onValueChange={setClienteFilter}
|
onValueChange={setClienteFilter}
|
||||||
options={companyOptions}
|
options={companyOptions}
|
||||||
placeholder="Filtrar por cliente"
|
placeholder="Cliente"
|
||||||
className="w-[200px]"
|
allowClear
|
||||||
|
clearLabel="Todos os clientes"
|
||||||
|
triggerClassName={fieldTrigger}
|
||||||
|
prefix={<IconBuilding className="size-4 text-neutral-400" />}
|
||||||
|
align="center"
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setSearchQuery("")
|
|
||||||
setStatusFilter("all")
|
|
||||||
setClienteFilter(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RotateCcw className="mr-2 size-4" />
|
|
||||||
Limpar
|
|
||||||
</Button>
|
|
||||||
</div>
|
</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 */}
|
{/* Table Section */}
|
||||||
|
<section className="rounded-3xl border border-slate-200 bg-white/90 shadow-sm overflow-hidden">
|
||||||
{!emprestimos ? (
|
{!emprestimos ? (
|
||||||
<div className="flex items-center justify-center py-10">
|
<div className="flex items-center justify-center py-16">
|
||||||
<Spinner className="size-8" />
|
<Spinner className="size-8" />
|
||||||
</div>
|
</div>
|
||||||
) : filteredEmprestimos.length === 0 ? (
|
) : filteredEmprestimos.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-10 text-center">
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
<Package className="mb-4 size-12 text-muted-foreground/50" />
|
<IconPackage className="mb-4 size-12 text-neutral-300" />
|
||||||
<p className="text-muted-foreground">Nenhum emprestimo encontrado.</p>
|
<p className="text-neutral-500">Nenhum empréstimo encontrado.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto rounded-md border">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow className="bg-slate-50/80 hover:bg-slate-50/80">
|
||||||
<TableHead>Ref</TableHead>
|
<TableHead className="font-semibold text-neutral-600">Ref</TableHead>
|
||||||
<TableHead>Cliente</TableHead>
|
<TableHead className="font-semibold text-neutral-600">Cliente</TableHead>
|
||||||
<TableHead>Responsavel</TableHead>
|
<TableHead className="font-semibold text-neutral-600">Responsável</TableHead>
|
||||||
<TableHead>Equipamentos</TableHead>
|
<TableHead className="font-semibold text-neutral-600">Equipamentos</TableHead>
|
||||||
<TableHead>Data emprestimo</TableHead>
|
<TableHead className="font-semibold text-neutral-600">Data empréstimo</TableHead>
|
||||||
<TableHead>Data fim</TableHead>
|
<TableHead className="font-semibold text-neutral-600">Data prevista</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead className="font-semibold text-neutral-600">Status</TableHead>
|
||||||
<TableHead>Valor</TableHead>
|
<TableHead className="font-semibold text-neutral-600">Valor</TableHead>
|
||||||
<TableHead className="text-right">Acoes</TableHead>
|
<TableHead className="text-right font-semibold text-neutral-600">Ações</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredEmprestimos.map((emp) => (
|
{filteredEmprestimos.map((emp) => (
|
||||||
<TableRow key={emp.id}>
|
<TableRow key={emp.id} className="transition-colors hover:bg-cyan-50/30">
|
||||||
<TableCell className="font-medium">#{emp.reference}</TableCell>
|
<TableCell className="font-semibold text-cyan-700">#{emp.reference}</TableCell>
|
||||||
<TableCell>{emp.clienteNome}</TableCell>
|
<TableCell className="font-medium">{emp.clienteNome}</TableCell>
|
||||||
<TableCell>{emp.responsavelNome}</TableCell>
|
<TableCell>{emp.responsavelNome}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="text-sm">
|
<span className="text-sm text-neutral-600">
|
||||||
{emp.quantidade} item(s):{" "}
|
{emp.quantidade} item(s):{" "}
|
||||||
{emp.equipamentos
|
{emp.equipamentos
|
||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
|
|
@ -516,10 +573,10 @@ export function EmprestimosPageClient() {
|
||||||
{emp.status === "ATIVO" && (
|
{emp.status === "ATIVO" && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
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)}
|
onClick={() => openDevolverDialog(emp.id)}
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="mr-1 size-3" />
|
<IconCircleCheck className="size-3.5" />
|
||||||
Devolver
|
Devolver
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -530,16 +587,15 @@ export function EmprestimosPageClient() {
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</section>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Create Dialog */}
|
{/* Create Dialog */}
|
||||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Novo emprestimo</DialogTitle>
|
<DialogTitle>Novo empréstimo</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Registre um novo emprestimo de equipamentos.
|
Registre um novo empréstimo de equipamentos.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -552,30 +608,32 @@ export function EmprestimosPageClient() {
|
||||||
onValueChange={setFormClienteId}
|
onValueChange={setFormClienteId}
|
||||||
options={companyOptions}
|
options={companyOptions}
|
||||||
placeholder="Selecione o cliente"
|
placeholder="Selecione o cliente"
|
||||||
|
prefix={<IconBuilding className="size-4 text-neutral-400" />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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
|
<SearchableCombobox
|
||||||
value={formTecnicoId}
|
value={formTecnicoId}
|
||||||
onValueChange={setFormTecnicoId}
|
onValueChange={setFormTecnicoId}
|
||||||
options={userOptions}
|
options={userOptions}
|
||||||
placeholder="Selecione o tecnico"
|
placeholder="Selecione o técnico"
|
||||||
|
prefix={<IconUser className="size-4 text-neutral-400" />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-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
|
<Input
|
||||||
value={formResponsavelNome}
|
value={formResponsavelNome}
|
||||||
onChange={(e) => setFormResponsavelNome(e.target.value)}
|
onChange={(e) => setFormResponsavelNome(e.target.value)}
|
||||||
placeholder="Nome do responsavel"
|
placeholder="Nome do responsável"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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
|
<Input
|
||||||
value={formResponsavelContato}
|
value={formResponsavelContato}
|
||||||
onChange={(e) => setFormResponsavelContato(e.target.value)}
|
onChange={(e) => setFormResponsavelContato(e.target.value)}
|
||||||
|
|
@ -586,11 +644,11 @@ export function EmprestimosPageClient() {
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-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} />
|
<DatePicker value={formDataEmprestimo} onChange={setFormDataEmprestimo} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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} />
|
<DatePicker value={formDataFim} onChange={setFormDataFim} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -607,7 +665,7 @@ export function EmprestimosPageClient() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
|
|
@ -621,28 +679,36 @@ export function EmprestimosPageClient() {
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-sm font-medium">Equipamentos *</label>
|
<label className="text-sm font-medium">Equipamentos *</label>
|
||||||
<Button type="button" size="sm" variant="outline" onClick={handleAddEquipamento}>
|
<Button
|
||||||
<Plus className="mr-1 size-3" />
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleAddEquipamento}
|
||||||
|
className="h-8 gap-1.5 rounded-full"
|
||||||
|
>
|
||||||
|
<IconPlus className="size-3.5" />
|
||||||
Adicionar
|
Adicionar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{formEquipamentos.length === 0 ? (
|
{formEquipamentos.length === 0 ? (
|
||||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
<div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-slate-200 py-8">
|
||||||
Nenhum equipamento adicionado.
|
<IconPackage className="mb-2 size-8 text-neutral-300" />
|
||||||
</p>
|
<p className="text-sm text-neutral-500">Nenhum equipamento adicionado.</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{formEquipamentos.map((eq, idx) => (
|
{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">
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
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)}
|
onClick={() => handleRemoveEquipamento(eq.id)}
|
||||||
>
|
>
|
||||||
|
<IconTrash className="size-3.5" />
|
||||||
Remover
|
Remover
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -651,7 +717,7 @@ export function EmprestimosPageClient() {
|
||||||
value={eq.tipo}
|
value={eq.tipo}
|
||||||
onValueChange={(v) => handleEquipamentoChange(eq.id, "tipo", v)}
|
onValueChange={(v) => handleEquipamentoChange(eq.id, "tipo", v)}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="h-9">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -666,18 +732,21 @@ export function EmprestimosPageClient() {
|
||||||
placeholder="Marca"
|
placeholder="Marca"
|
||||||
value={eq.marca}
|
value={eq.marca}
|
||||||
onChange={(e) => handleEquipamentoChange(eq.id, "marca", e.target.value)}
|
onChange={(e) => handleEquipamentoChange(eq.id, "marca", e.target.value)}
|
||||||
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Modelo"
|
placeholder="Modelo"
|
||||||
value={eq.modelo}
|
value={eq.modelo}
|
||||||
onChange={(e) => handleEquipamentoChange(eq.id, "modelo", e.target.value)}
|
onChange={(e) => handleEquipamentoChange(eq.id, "modelo", e.target.value)}
|
||||||
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Serial/Patrimonio"
|
placeholder="Serial/Patrimônio"
|
||||||
value={eq.serialNumber ?? ""}
|
value={eq.serialNumber ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleEquipamentoChange(eq.id, "serialNumber", e.target.value)
|
handleEquipamentoChange(eq.id, "serialNumber", e.target.value)
|
||||||
}
|
}
|
||||||
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -687,28 +756,32 @@ export function EmprestimosPageClient() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Observacoes</label>
|
<label className="text-sm font-medium">Observações</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={formObservacoes}
|
value={formObservacoes}
|
||||||
onChange={(e) => setFormObservacoes(e.target.value)}
|
onChange={(e) => setFormObservacoes(e.target.value)}
|
||||||
placeholder="Observacoes sobre o emprestimo..."
|
placeholder="Observações sobre o empréstimo..."
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
|
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)} className="rounded-full">
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</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 ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Spinner className="mr-2 size-4" />
|
<Spinner className="size-4" />
|
||||||
Criando...
|
Criando...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Criar emprestimo"
|
"Criar empréstimo"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
@ -719,38 +792,42 @@ export function EmprestimosPageClient() {
|
||||||
<Dialog open={isDevolverDialogOpen} onOpenChange={setIsDevolverDialogOpen}>
|
<Dialog open={isDevolverDialogOpen} onOpenChange={setIsDevolverDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Registrar devolucao</DialogTitle>
|
<DialogTitle>Registrar devolução</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Confirme a devolucao dos equipamentos emprestados.
|
Confirme a devolução dos equipamentos emprestados.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<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
|
<Textarea
|
||||||
value={formObservacoes}
|
value={formObservacoes}
|
||||||
onChange={(e) => setFormObservacoes(e.target.value)}
|
onChange={(e) => setFormObservacoes(e.target.value)}
|
||||||
placeholder="Condicao dos equipamentos, observacoes..."
|
placeholder="Condição dos equipamentos, observações..."
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setIsDevolverDialogOpen(false)}>
|
<Button variant="outline" onClick={() => setIsDevolverDialogOpen(false)} className="rounded-full">
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</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 ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Spinner className="mr-2 size-4" />
|
<Spinner className="size-4" />
|
||||||
Registrando...
|
Registrando...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CheckCircle2 className="mr-2 size-4" />
|
<IconCircleCheck className="size-4" />
|
||||||
Confirmar devolucao
|
Confirmar devolução
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1269,6 +1269,7 @@ export function AdminDevicesOverview({
|
||||||
const [selectedUsbPolicy, setSelectedUsbPolicy] = useState<"ALLOW" | "BLOCK_ALL" | "READONLY">("ALLOW")
|
const [selectedUsbPolicy, setSelectedUsbPolicy] = useState<"ALLOW" | "BLOCK_ALL" | "READONLY">("ALLOW")
|
||||||
const [isApplyingUsbPolicy, setIsApplyingUsbPolicy] = useState(false)
|
const [isApplyingUsbPolicy, setIsApplyingUsbPolicy] = useState(false)
|
||||||
const [usbPolicySelection, setUsbPolicySelection] = useState<string[]>([])
|
const [usbPolicySelection, setUsbPolicySelection] = useState<string[]>([])
|
||||||
|
const [usbCompanyFilter, setUsbCompanyFilter] = useState<string>("all")
|
||||||
const [isCreateDeviceOpen, setIsCreateDeviceOpen] = useState(false)
|
const [isCreateDeviceOpen, setIsCreateDeviceOpen] = useState(false)
|
||||||
const [createDeviceLoading, setCreateDeviceLoading] = useState(false)
|
const [createDeviceLoading, setCreateDeviceLoading] = useState(false)
|
||||||
const [newDeviceName, setNewDeviceName] = useState("")
|
const [newDeviceName, setNewDeviceName] = useState("")
|
||||||
|
|
@ -1713,15 +1714,18 @@ export function AdminDevicesOverview({
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleSelectAllUsbDevices = useCallback((checked: boolean) => {
|
const handleSelectAllUsbDevices = useCallback((checked: boolean) => {
|
||||||
const windowsDevices = filteredDevices.filter(
|
const allWindowsDevices = filteredDevices.filter(
|
||||||
(m) => (m.devicePlatform ?? "").toLowerCase() === "windows"
|
(m) => (m.devicePlatform ?? "").toLowerCase() === "windows"
|
||||||
)
|
)
|
||||||
|
const windowsDevices = usbCompanyFilter === "all"
|
||||||
|
? allWindowsDevices
|
||||||
|
: allWindowsDevices.filter((m) => (m.companySlug ?? "") === usbCompanyFilter)
|
||||||
if (checked) {
|
if (checked) {
|
||||||
setUsbPolicySelection(windowsDevices.map((m) => m.id))
|
setUsbPolicySelection(windowsDevices.map((m) => m.id))
|
||||||
} else {
|
} else {
|
||||||
setUsbPolicySelection([])
|
setUsbPolicySelection([])
|
||||||
}
|
}
|
||||||
}, [filteredDevices])
|
}, [filteredDevices, usbCompanyFilter])
|
||||||
|
|
||||||
const handleApplyBulkUsbPolicy = useCallback(async () => {
|
const handleApplyBulkUsbPolicy = useCallback(async () => {
|
||||||
if (usbPolicySelection.length === 0) {
|
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">
|
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Dispositivos registrados</CardTitle>
|
<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>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button size="sm" onClick={handleOpenCreateDevice}>
|
<Button size="sm" onClick={handleOpenCreateDevice}>
|
||||||
|
|
@ -2199,20 +2203,55 @@ export function AdminDevicesOverview({
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{(() => {
|
{(() => {
|
||||||
const windowsDevices = filteredDevices.filter(
|
const allWindowsDevices = filteredDevices.filter(
|
||||||
(m) => (m.devicePlatform ?? "").toLowerCase() === "windows"
|
(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 (
|
return (
|
||||||
<div className="rounded-md border border-dashed border-slate-200 px-4 py-8 text-center text-sm text-muted-foreground">
|
<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.
|
Nenhum dispositivo Windows disponível com os filtros atuais.
|
||||||
</div>
|
</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
|
const someSelected = usbPolicySelection.length > 0 && usbPolicySelection.length < windowsDevices.length
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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">
|
<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>
|
<span>
|
||||||
{usbPolicySelection.length} de {windowsDevices.length} dispositivos Windows selecionados
|
{usbPolicySelection.length} de {windowsDevices.length} dispositivos Windows selecionados
|
||||||
|
|
@ -2220,9 +2259,14 @@ export function AdminDevicesOverview({
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={allSelected ? true : someSelected ? "indeterminate" : false}
|
checked={allSelected ? true : someSelected ? "indeterminate" : false}
|
||||||
onCheckedChange={(value) => handleSelectAllUsbDevices(value === true || value === "indeterminate")}
|
onCheckedChange={(value) => handleSelectAllUsbDevices(value === true || value === "indeterminate")}
|
||||||
disabled={isApplyingUsbPolicy}
|
disabled={isApplyingUsbPolicy || windowsDevices.length === 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="max-h-48 space-y-1 overflow-y-auto rounded-md border border-slate-200 p-2">
|
||||||
{windowsDevices.map((device) => {
|
{windowsDevices.map((device) => {
|
||||||
const checked = usbPolicySelection.includes(device.id)
|
const checked = usbPolicySelection.includes(device.id)
|
||||||
|
|
@ -2249,6 +2293,7 @@ export function AdminDevicesOverview({
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-700">Política USB</label>
|
<label className="text-sm font-medium text-slate-700">Política USB</label>
|
||||||
<Select
|
<Select
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ const navigation: NavigationGroup[] = [
|
||||||
{ title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" },
|
{ title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" },
|
||||||
{ title: "Agenda", url: "/agenda", icon: CalendarDays, requiredRole: "staff" },
|
{ title: "Agenda", url: "/agenda", icon: CalendarDays, requiredRole: "staff" },
|
||||||
{ title: "Dispositivos", url: "/admin/devices", icon: MonitorCog, requiredRole: "admin" },
|
{ 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