Add PocketBase login, first-sign-in password changes, account settings, and user activation, reactivation and deletion with self-removal guards. Protect application pages and APIs and keep private pages out of PWA caches. Resolve plant locations from site assignments and improve map controls, profit chart views, navigation and mobile table layouts. Validation: production build and TypeScript passed; 28 unit tests passed. Backend access integration tests and mobile verification are documented.
100 lines
3.6 KiB
TypeScript
100 lines
3.6 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useMemo } from "react";
|
|
import Link from "next/link";
|
|
import { Search } from "lucide-react";
|
|
import { useRouter } from "next/navigation";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type Asset = {
|
|
id: string;
|
|
code: string;
|
|
name: string;
|
|
category: string;
|
|
supplier: string;
|
|
};
|
|
|
|
export function GlobalSearch({ assets }: { assets: Asset[] }) {
|
|
const [query, setQuery] = useState("");
|
|
const [open, setOpen] = useState(false);
|
|
const router = useRouter();
|
|
|
|
const matches = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
if (q.length < 2) return [];
|
|
return assets
|
|
.filter(
|
|
(a) =>
|
|
a.code.toLowerCase().includes(q) ||
|
|
a.name.toLowerCase().includes(q) ||
|
|
(a.supplier || "").toLowerCase().includes(q)
|
|
)
|
|
.slice(0, 8);
|
|
}, [query, assets]);
|
|
|
|
return (
|
|
<div className="relative flex-1 min-w-0 max-w-md">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-text-soft pointer-events-none" />
|
|
<input
|
|
value={query}
|
|
onChange={(e) => {
|
|
setQuery(e.target.value);
|
|
setOpen(true);
|
|
}}
|
|
onFocus={() => setOpen(true)}
|
|
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && matches[0]) {
|
|
router.push(`/income?asset=${matches[0].id}`);
|
|
setOpen(false);
|
|
setQuery("");
|
|
}
|
|
}}
|
|
aria-label="Search plant, supplier or code"
|
|
placeholder="Search plant…"
|
|
className="w-full h-11 pl-9 pr-3 bg-gray-100 dark:bg-white/5 border border-transparent rounded-lg text-sm placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:bg-white dark:focus:bg-white/10 focus:border-[#30638E] focus:outline-none transition"
|
|
/>
|
|
</div>
|
|
|
|
{open && matches.length > 0 && (
|
|
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-surface border border-gray-100 dark:border-white/10 rounded-xl shadow-lg overflow-hidden z-50">
|
|
{matches.map((a) => (
|
|
<Link
|
|
key={a.id}
|
|
href={`/income?asset=${a.id}`}
|
|
onClick={() => {
|
|
setOpen(false);
|
|
setQuery("");
|
|
}}
|
|
className={cn(
|
|
"flex items-center gap-3 px-4 py-2.5 hover:bg-gray-50 dark:hover:bg-white/5 text-sm border-b border-gray-50 dark:border-border last:border-0"
|
|
)}
|
|
>
|
|
{a.code ? (
|
|
<span className="font-mono text-xs px-2 py-0.5 rounded bg-[#00798C]/10 text-[#00798C] font-semibold shrink-0">
|
|
{a.code}
|
|
</span>
|
|
) : (
|
|
<span className="text-[10px] px-2 py-0.5 rounded bg-gray-100 dark:bg-white/5 text-gray-400 shrink-0">
|
|
no code
|
|
</span>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium text-[#003D5B] dark:text-white truncate">{a.name}</div>
|
|
{a.supplier && (
|
|
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">{a.supplier}</div>
|
|
)}
|
|
</div>
|
|
{a.category && (
|
|
<span className="text-[10px] uppercase tracking-wider text-gray-400 dark:text-text-soft shrink-0">
|
|
{a.category}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|