Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions frontend/opsce/features/assets/AdvancedSearchBar.tsx
Original file line number Diff line number Diff line change
@@ -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<FilterRow[]>([
{ 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<FilterRow>) =>
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 (
<div className="flex gap-2">
<input
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-900"
placeholder="Search assets..."
value={simpleQuery}
onChange={(e) => setSimpleQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && applySimple()}
/>
<button
onClick={applySimple}
className="px-3 py-2 text-sm bg-gray-900 text-white rounded-lg hover:bg-gray-700"
>
Search
</button>
<button
onClick={() => setAdvanced(true)}
className="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-600"
>
Advanced
</button>
</div>
);
}

return (
<div className="border border-gray-200 rounded-xl p-4 space-y-3 bg-white">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-900">Advanced Search</span>
<button
onClick={() => setAdvanced(false)}
className="text-xs text-gray-500 hover:text-gray-900"
>
Simple
</button>
</div>

{filters.map((row) => (
<div key={row.id} className="flex gap-2 items-center">
<select
className="px-2 py-1.5 text-sm border border-gray-300 rounded-lg flex-shrink-0"
value={row.field}
onChange={(e) => updateFilter(row.id, { field: e.target.value as Field })}
>
{FIELDS.map((f) => (
<option key={f}>{f}</option>
))}
</select>

<select
className="px-2 py-1.5 text-sm border border-gray-300 rounded-lg flex-shrink-0"
value={row.operator}
onChange={(e) => updateFilter(row.id, { operator: e.target.value as Operator })}
>
{OPERATORS.map((o) => (
<option key={o}>{o}</option>
))}
</select>

{row.operator !== 'is empty' && (
<input
className="flex-1 px-2 py-1.5 text-sm border border-gray-300 rounded-lg"
value={row.value}
onChange={(e) => updateFilter(row.id, { value: e.target.value })}
placeholder="Value..."
/>
)}

<button
onClick={() => removeFilter(row.id)}
className="text-gray-400 hover:text-red-500 flex-shrink-0"
aria-label="Remove filter"
>
<X size={16} />
</button>
</div>
))}

<div className="flex gap-2">
<button
onClick={addFilter}
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-900"
>
<Plus size={14} /> Add filter
</button>
<div className="flex-1" />
<button
onClick={applyAdvanced}
className="px-4 py-1.5 text-sm bg-gray-900 text-white rounded-lg hover:bg-gray-700"
>
Apply
</button>
</div>
</div>
);
}
184 changes: 184 additions & 0 deletions frontend/opsce/features/commandPalette/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -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<AssetResult[]>([]);
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();

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 (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-20 px-4">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-gray-900 rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden">
{/* Search input */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-100 dark:border-gray-800">
<Search size={18} className="text-gray-400 flex-shrink-0" />
<input
ref={inputRef}
value={query}
onChange={(e) => 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 && (
<button onClick={() => setQuery('')} aria-label="Clear">
<X size={16} className="text-gray-400" />
</button>
)}
</div>

{/* Results */}
<div className="max-h-80 overflow-y-auto py-2">
{filteredNav.length > 0 && (
<div>
<p className="px-4 py-1 text-xs font-medium text-gray-400 uppercase tracking-wide">
Pages
</p>
{filteredNav.map((cmd) => (
<button
key={cmd.href}
onClick={() => navigate(cmd.href)}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 text-left"
>
<cmd.icon size={16} className="text-gray-400" />
{cmd.label}
</button>
))}
</div>
)}

{assets.length > 0 && (
<div>
<p className="px-4 py-1 text-xs font-medium text-gray-400 uppercase tracking-wide mt-1">
Assets
</p>
{assets.map((asset) => (
<button
key={asset.id}
onClick={() => navigate(`/assets/${asset.id}`)}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 text-left"
>
<Package size={16} className="text-gray-400" />
<span>{asset.name}</span>
<span className="ml-auto text-xs text-gray-400">{asset.assetTag}</span>
</button>
))}
</div>
)}

{loading && (
<p className="px-4 py-3 text-sm text-gray-400">Searching...</p>
)}

{!loading &&
query.length >= 3 &&
assets.length === 0 &&
filteredNav.length === 0 && (
<p className="px-4 py-3 text-sm text-gray-400">
No results for &quot;{query}&quot;
</p>
)}
</div>

{/* Footer hint */}
<div className="px-4 py-2 border-t border-gray-100 dark:border-gray-800 flex gap-3 text-xs text-gray-400">
<span>↑↓ navigate</span>
<span>↵ select</span>
<span>Esc close</span>
</div>
</div>
</div>
);
}
26 changes: 26 additions & 0 deletions frontend/opsce/features/commandPalette/CommandPaletteProvider.tsx
Original file line number Diff line number Diff line change
@@ -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}
<CommandPalette open={open} onClose={() => setOpen(false)} />
</>
);
}
Loading
Loading