From c41c69fede2ade56f79d7088f1d3eedb7d04dc67 Mon Sep 17 00:00:00 2001 From: TomikeDS Date: Sat, 30 May 2026 12:21:17 +0100 Subject: [PATCH] feat: add AssetConditionTrendChart, AdvancedSearchBar, useTheme, and CommandPalette --- .../features/assets/AdvancedSearchBar.tsx | 180 +++++++++++++++++ .../commandPalette/CommandPalette.tsx | 184 ++++++++++++++++++ .../commandPalette/CommandPaletteProvider.tsx | 26 +++ .../reports/AssetConditionTrendChart.tsx | 93 +++++++++ .../opsce/features/settings/ThemeToggle.tsx | 18 ++ frontend/opsce/hooks/index.ts | 1 + frontend/opsce/hooks/useTheme.ts | 30 +++ 7 files changed, 532 insertions(+) create mode 100644 frontend/opsce/features/assets/AdvancedSearchBar.tsx create mode 100644 frontend/opsce/features/commandPalette/CommandPalette.tsx create mode 100644 frontend/opsce/features/commandPalette/CommandPaletteProvider.tsx create mode 100644 frontend/opsce/features/reports/AssetConditionTrendChart.tsx create mode 100644 frontend/opsce/features/settings/ThemeToggle.tsx create mode 100644 frontend/opsce/hooks/index.ts create mode 100644 frontend/opsce/hooks/useTheme.ts diff --git a/frontend/opsce/features/assets/AdvancedSearchBar.tsx b/frontend/opsce/features/assets/AdvancedSearchBar.tsx new file mode 100644 index 00000000..2b7f29b3 --- /dev/null +++ b/frontend/opsce/features/assets/AdvancedSearchBar.tsx @@ -0,0 +1,180 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { Plus, X } from 'lucide-react'; +import { useRouter, useSearchParams, usePathname } from 'next/navigation'; + +const FIELDS = [ + 'Name', + 'Serial Number', + 'Category', + 'Status', + 'Condition', + 'Department', + 'Location', + 'Assigned To', + 'Purchase Date', + 'Value', +] as const; + +const OPERATORS = [ + 'contains', + 'equals', + 'greater than', + 'less than', + 'is empty', +] as const; + +type Field = (typeof FIELDS)[number]; +type Operator = (typeof OPERATORS)[number]; + +interface FilterRow { + id: string; + field: Field; + operator: Operator; + value: string; +} + +function uid() { + return Math.random().toString(36).slice(2); +} + +interface Props { + onSearch?: (params: URLSearchParams) => void; +} + +export function AdvancedSearchBar({ onSearch }: Props) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [advanced, setAdvanced] = useState(false); + const [simpleQuery, setSimpleQuery] = useState(searchParams.get('search') ?? ''); + const [filters, setFilters] = useState([ + { id: uid(), field: 'Name', operator: 'contains', value: '' }, + ]); + + const addFilter = () => + setFilters((f) => [...f, { id: uid(), field: 'Name', operator: 'contains', value: '' }]); + + const removeFilter = (id: string) => setFilters((f) => f.filter((r) => r.id !== id)); + + const updateFilter = (id: string, patch: Partial) => + setFilters((f) => f.map((r) => (r.id === id ? { ...r, ...patch } : r))); + + const applyAdvanced = useCallback(() => { + const params = new URLSearchParams(); + filters.forEach((f, i) => { + if (f.operator !== 'is empty' && !f.value) return; + params.set(`filter[${i}][field]`, f.field); + params.set(`filter[${i}][op]`, f.operator); + if (f.operator !== 'is empty') params.set(`filter[${i}][value]`, f.value); + }); + const qs = params.toString(); + router.push(`${pathname}${qs ? `?${qs}` : ''}`); + onSearch?.(params); + }, [filters, pathname, router, onSearch]); + + const applySimple = useCallback(() => { + const params = new URLSearchParams(); + if (simpleQuery) params.set('search', simpleQuery); + router.push(`${pathname}${params.toString() ? `?${params}` : ''}`); + onSearch?.(params); + }, [simpleQuery, pathname, router, onSearch]); + + if (!advanced) { + return ( +
+ setSimpleQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && applySimple()} + /> + + +
+ ); + } + + return ( +
+
+ Advanced Search + +
+ + {filters.map((row) => ( +
+ + + + + {row.operator !== 'is empty' && ( + updateFilter(row.id, { value: e.target.value })} + placeholder="Value..." + /> + )} + + +
+ ))} + +
+ +
+ +
+
+ ); +} diff --git a/frontend/opsce/features/commandPalette/CommandPalette.tsx b/frontend/opsce/features/commandPalette/CommandPalette.tsx new file mode 100644 index 00000000..cc05a23a --- /dev/null +++ b/frontend/opsce/features/commandPalette/CommandPalette.tsx @@ -0,0 +1,184 @@ +'use client'; + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useRouter } from 'next/navigation'; +import { + Search, + LayoutDashboard, + Package, + Users, + Building2, + BarChart3, + X, +} from 'lucide-react'; + +const NAV_COMMANDS = [ + { label: 'Go to Dashboard', href: '/dashboard', icon: LayoutDashboard }, + { label: 'Go to Assets', href: '/assets', icon: Package }, + { label: 'Go to Departments', href: '/departments', icon: Building2 }, + { label: 'Go to Reports', href: '/reports', icon: BarChart3 }, + { label: 'Go to Users', href: '/users', icon: Users }, +]; + +interface AssetResult { + id: string; + name: string; + assetTag: string; +} + +interface Props { + open: boolean; + onClose: () => void; +} + +export function CommandPalette({ open, onClose }: Props) { + const router = useRouter(); + const [query, setQuery] = useState(''); + const [assets, setAssets] = useState([]); + const [loading, setLoading] = useState(false); + const inputRef = useRef(null); + const debounceRef = useRef>(); + + useEffect(() => { + if (open) { + setQuery(''); + setAssets([]); + setTimeout(() => inputRef.current?.focus(), 50); + } + }, [open]); + + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [open, onClose]); + + const searchAssets = useCallback((q: string) => { + if (q.length < 3) { + setAssets([]); + return; + } + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(async () => { + setLoading(true); + try { + const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:6003/api'; + const token = + typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''; + const res = await fetch( + `${API}/assets?search=${encodeURIComponent(q)}&limit=5`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + if (res.ok) { + const data = await res.json(); + setAssets(data.data ?? data ?? []); + } + } catch { + setAssets([]); + } + setLoading(false); + }, 250); + }, []); + + useEffect(() => { + searchAssets(query); + }, [query, searchAssets]); + + const navigate = (href: string) => { + router.push(href); + onClose(); + }; + + const filteredNav = NAV_COMMANDS.filter( + (c) => !query || c.label.toLowerCase().includes(query.toLowerCase()), + ); + + if (!open) return null; + + return ( +
+
+
+ {/* Search input */} +
+ + setQuery(e.target.value)} + className="flex-1 text-sm bg-transparent outline-none text-gray-900 dark:text-gray-100 placeholder:text-gray-400" + placeholder="Search pages and assets..." + /> + {query && ( + + )} +
+ + {/* Results */} +
+ {filteredNav.length > 0 && ( +
+

+ Pages +

+ {filteredNav.map((cmd) => ( + + ))} +
+ )} + + {assets.length > 0 && ( +
+

+ Assets +

+ {assets.map((asset) => ( + + ))} +
+ )} + + {loading && ( +

Searching...

+ )} + + {!loading && + query.length >= 3 && + assets.length === 0 && + filteredNav.length === 0 && ( +

+ No results for "{query}" +

+ )} +
+ + {/* Footer hint */} +
+ ↑↓ navigate + ↵ select + Esc close +
+
+
+ ); +} diff --git a/frontend/opsce/features/commandPalette/CommandPaletteProvider.tsx b/frontend/opsce/features/commandPalette/CommandPaletteProvider.tsx new file mode 100644 index 00000000..533c7ec6 --- /dev/null +++ b/frontend/opsce/features/commandPalette/CommandPaletteProvider.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { CommandPalette } from './CommandPalette'; + +export function CommandPaletteProvider({ children }: { children: React.ReactNode }) { + const [open, setOpen] = useState(false); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault(); + setOpen((v) => !v); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + + return ( + <> + {children} + setOpen(false)} /> + + ); +} diff --git a/frontend/opsce/features/reports/AssetConditionTrendChart.tsx b/frontend/opsce/features/reports/AssetConditionTrendChart.tsx new file mode 100644 index 00000000..89260ce3 --- /dev/null +++ b/frontend/opsce/features/reports/AssetConditionTrendChart.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, + ReferenceLine, +} from 'recharts'; + +const CONDITION_SCORE: Record = { + Excellent: 4, + Good: 3, + Fair: 2, + Poor: 1, +}; + +const SCORE_LABEL: Record = { + 4: 'Excellent', + 3: 'Good', + 2: 'Fair', + 1: 'Poor', +}; + +const COLORS = ['#6366f1', '#f59e0b', '#10b981', '#ef4444', '#8b5cf6']; + +interface DataPoint { + date: string; + condition: string; +} + +interface AssetSeries { + assetId: string; + name: string; + history: DataPoint[]; +} + +interface Props { + series: AssetSeries[]; +} + +export function AssetConditionTrendChart({ series }: Props) { + const allDates = [ + ...new Set(series.flatMap((s) => s.history.map((h) => h.date))), + ].sort(); + + const chartData = allDates.map((date) => { + const point: Record = { date }; + series.forEach((s) => { + const found = s.history.find((h) => h.date === date); + point[s.assetId] = found ? (CONDITION_SCORE[found.condition] ?? null) : null; + }); + return point; + }); + + return ( + + + + + SCORE_LABEL[v] ?? ''} + tick={{ fontSize: 11 }} + /> + SCORE_LABEL[value] ?? value} /> + + + {series.map((s, i) => ( + + ))} + + + ); +} diff --git a/frontend/opsce/features/settings/ThemeToggle.tsx b/frontend/opsce/features/settings/ThemeToggle.tsx new file mode 100644 index 00000000..c8382870 --- /dev/null +++ b/frontend/opsce/features/settings/ThemeToggle.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { Sun, Moon } from 'lucide-react'; +import { useTheme } from '@/opsce/hooks/useTheme'; + +export function ThemeToggle() { + const { theme, toggleTheme } = useTheme(); + + return ( + + ); +} diff --git a/frontend/opsce/hooks/index.ts b/frontend/opsce/hooks/index.ts new file mode 100644 index 00000000..f637b7a3 --- /dev/null +++ b/frontend/opsce/hooks/index.ts @@ -0,0 +1 @@ +export { useTheme } from './useTheme'; diff --git a/frontend/opsce/hooks/useTheme.ts b/frontend/opsce/hooks/useTheme.ts new file mode 100644 index 00000000..b0daf908 --- /dev/null +++ b/frontend/opsce/hooks/useTheme.ts @@ -0,0 +1,30 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +type Theme = 'light' | 'dark'; + +export function useTheme() { + const [theme, setTheme] = useState('light'); + + useEffect(() => { + const saved = localStorage.getItem('theme') as Theme | null; + const preferred = window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; + const initial = saved ?? preferred; + setTheme(initial); + document.documentElement.classList.toggle('dark', initial === 'dark'); + }, []); + + const toggleTheme = () => { + setTheme((prev) => { + const next: Theme = prev === 'light' ? 'dark' : 'light'; + localStorage.setItem('theme', next); + document.documentElement.classList.toggle('dark', next === 'dark'); + return next; + }); + }; + + return { theme, toggleTheme }; +}