feat(search): wire ⌘K palette to all entities + quick actions
Replaces the static template list with a working multi-entity command
palette tied to the active workspace.
Server (lib/appwrite/search-actions.ts):
- globalSearchAction(query): runs after the user has typed >= 2 chars.
Pulls up to 200 rows per entity for the active tenant via requireTenant
and admin SDK, then in-memory filters on:
customers: name, email, phone, taxId
invoices: number, notes, customer name (via id->name map)
tasks: title, description
services: name, description
software: name, version, description
calendar events: title, description
finance entries: description, customer name, amount string
- Returns at most 8 hits per group. Each hit has { title, subtitle, url,
group } so the client doesn't need extra lookups. Turkish-aware
toLocaleLowerCase('tr-TR').
Client (components/command-search.tsx):
- Rewritten. cmdk Command with shouldFilter=false (we provide filtered
results from the server).
- 220ms debounce on input; spinner during fetch.
- Ordered groups: Müşteriler / Faturalar / Görevler / Takvim / Finans /
Hizmetler / Yazılımlar — each with its own icon.
- Static groups always evaluated client-side from the typed query:
* Sayfalar (8 nav items)
* Hızlı aksiyonlar (5 — yeni müşteri / fatura / görev / etkinlik /
finans girişi)
* Ayarlar (3 — şirket bilgileri / ekip / profil)
- Empty-state message ('Sonuç bulunamadı') only shown when the query
is non-trivial AND nothing matches anywhere.
- Footer hint row with ↵/↑↓/Esc/⌘K legend.
- Invoice hits navigate to /invoices/[id]; other entity hits go to the
list page (no per-id detail routes for those yet).
Trigger button (SearchTrigger): localized to 'Hızlı ara...'.
This commit is contained in:
+267
-145
@@ -1,30 +1,34 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import {
|
||||
Search,
|
||||
LayoutDashboard,
|
||||
Mail,
|
||||
Briefcase,
|
||||
Calendar as CalendarIcon,
|
||||
CheckSquare,
|
||||
MessageCircle,
|
||||
Calendar,
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
CircleDollarSign,
|
||||
FilePlus,
|
||||
LayoutDashboard,
|
||||
Loader2,
|
||||
Package,
|
||||
Receipt,
|
||||
Search,
|
||||
Settings,
|
||||
HelpCircle,
|
||||
CreditCard,
|
||||
User,
|
||||
UserPlus,
|
||||
Users,
|
||||
Bell,
|
||||
Link2,
|
||||
Palette,
|
||||
Wallet,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
} from "lucide-react";
|
||||
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
globalSearchAction,
|
||||
type SearchGroup,
|
||||
type SearchHit,
|
||||
type SearchResults,
|
||||
} from "@/lib/appwrite/search-actions";
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
@@ -33,28 +37,32 @@ const Command = React.forwardRef<
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-xl bg-white dark:bg-zinc-950 text-zinc-950 dark:text-zinc-50",
|
||||
className
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-xl",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
shouldFilter={false}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-4">
|
||||
<Search className="text-muted-foreground mr-2 size-4" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-12 w-full border-none bg-transparent px-4 py-3 text-[17px] outline-none placeholder:text-zinc-500 dark:placeholder:text-zinc-400 border-b border-zinc-200 dark:border-zinc-800 mb-4",
|
||||
className
|
||||
"placeholder:text-muted-foreground flex h-12 w-full border-none bg-transparent py-3 text-base outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
</div>
|
||||
));
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
@@ -62,11 +70,11 @@ const CommandList = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[400px] overflow-y-auto overflow-x-hidden pb-2", className)}
|
||||
className={cn("max-h-[440px] overflow-y-auto overflow-x-hidden p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
));
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
@@ -74,11 +82,11 @@ const CommandEmpty = React.forwardRef<
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="flex h-12 items-center justify-center text-sm text-zinc-500 dark:text-zinc-400"
|
||||
className="text-muted-foreground py-8 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
));
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
@@ -87,13 +95,13 @@ const CommandGroup = React.forwardRef<
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden px-2 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-zinc-500 dark:[&_[cmdk-group-heading]]:text-zinc-400 [&:not(:first-child)]:mt-2",
|
||||
className
|
||||
"overflow-hidden [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&:not(:first-child)]:mt-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
));
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
@@ -102,150 +110,264 @@ const CommandItem = React.forwardRef<
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-12 cursor-pointer select-none items-center gap-2 rounded-lg px-4 text-sm text-zinc-700 dark:text-zinc-300 outline-none transition-colors data-[disabled=true]:pointer-events-none data-[selected=true]:bg-zinc-100 dark:data-[selected=true]:bg-zinc-800 data-[selected=true]:text-zinc-900 dark:data-[selected=true]:text-zinc-100 data-[disabled=true]:opacity-50 [&+[cmdk-item]]:mt-1",
|
||||
className
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground relative flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
));
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
interface SearchItem {
|
||||
title: string
|
||||
url: string
|
||||
group: string
|
||||
icon?: LucideIcon
|
||||
}
|
||||
type NavItem = { key: string; title: string; url: string; icon: LucideIcon };
|
||||
|
||||
const NAV: NavItem[] = [
|
||||
{ key: "nav-dashboard", title: "Genel bakış", url: "/dashboard", icon: LayoutDashboard },
|
||||
{ key: "nav-customers", title: "Müşteriler", url: "/customers", icon: Users },
|
||||
{ key: "nav-services", title: "Hizmetler", url: "/services", icon: Briefcase },
|
||||
{ key: "nav-software", title: "Yazılımlarımız", url: "/software", icon: Package },
|
||||
{ key: "nav-calendar", title: "Takvim", url: "/calendar", icon: CalendarIcon },
|
||||
{ key: "nav-tasks", title: "Görevler", url: "/tasks", icon: CheckSquare },
|
||||
{ key: "nav-finance", title: "Gelir / Gider", url: "/finance", icon: Wallet },
|
||||
{ key: "nav-invoices", title: "Faturalar", url: "/invoices", icon: Receipt },
|
||||
];
|
||||
|
||||
const QUICK_ACTIONS: NavItem[] = [
|
||||
{ key: "qa-customer", title: "Yeni müşteri ekle", url: "/customers", icon: UserPlus },
|
||||
{ key: "qa-invoice", title: "Yeni fatura kes", url: "/invoices", icon: Receipt },
|
||||
{ key: "qa-task", title: "Yeni görev oluştur", url: "/tasks", icon: FilePlus },
|
||||
{ key: "qa-event", title: "Takvime etkinlik ekle", url: "/calendar", icon: CalendarIcon },
|
||||
{ key: "qa-finance", title: "Yeni gelir/gider girişi", url: "/finance", icon: CircleDollarSign },
|
||||
];
|
||||
|
||||
const SETTINGS_NAV: NavItem[] = [
|
||||
{ key: "set-workspace", title: "Şirket bilgileri", url: "/settings/workspace", icon: Settings },
|
||||
{ key: "set-members", title: "Ekip üyeleri", url: "/settings/members", icon: Users },
|
||||
{ key: "set-account", title: "Profil", url: "/settings/account", icon: Settings },
|
||||
];
|
||||
|
||||
const GROUP_LABEL: Record<SearchGroup, string> = {
|
||||
customers: "Müşteriler",
|
||||
invoices: "Faturalar",
|
||||
tasks: "Görevler",
|
||||
services: "Hizmetler",
|
||||
software: "Yazılımlar",
|
||||
events: "Takvim",
|
||||
finance: "Finans",
|
||||
};
|
||||
|
||||
const GROUP_ICON: Record<SearchGroup, LucideIcon> = {
|
||||
customers: Users,
|
||||
invoices: Receipt,
|
||||
tasks: CheckSquare,
|
||||
services: Briefcase,
|
||||
software: Package,
|
||||
events: CalendarIcon,
|
||||
finance: Wallet,
|
||||
};
|
||||
|
||||
const GROUP_ORDER: SearchGroup[] = [
|
||||
"customers",
|
||||
"invoices",
|
||||
"tasks",
|
||||
"events",
|
||||
"finance",
|
||||
"services",
|
||||
"software",
|
||||
];
|
||||
|
||||
interface CommandSearchProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function filterNav(items: NavItem[], q: string): NavItem[] {
|
||||
if (!q) return items;
|
||||
const lower = q.toLocaleLowerCase("tr-TR");
|
||||
return items.filter((i) => i.title.toLocaleLowerCase("tr-TR").includes(lower));
|
||||
}
|
||||
|
||||
export function CommandSearch({ open, onOpenChange }: CommandSearchProps) {
|
||||
const router = useRouter()
|
||||
const commandRef = React.useRef<HTMLDivElement>(null)
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [results, setResults] = React.useState<SearchResults | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
const searchItems: SearchItem[] = [
|
||||
// Genel
|
||||
{ title: "Genel bakış", url: "/dashboard", group: "Genel", icon: LayoutDashboard },
|
||||
|
||||
// İşletme
|
||||
{ title: "Müşteriler", url: "/customers", group: "İşletme", icon: Users },
|
||||
{ title: "Hizmetler", url: "/services", group: "İşletme", icon: Mail },
|
||||
{ title: "Yazılımlarımız", url: "/software", group: "İşletme", icon: Mail },
|
||||
|
||||
// Operasyon
|
||||
{ title: "Takvim", url: "/calendar", group: "Operasyon", icon: Calendar },
|
||||
{ title: "Görevler", url: "/tasks", group: "Operasyon", icon: CheckSquare },
|
||||
|
||||
// Finans
|
||||
{ title: "Gelir / Gider", url: "/finance", group: "Finans", icon: Mail },
|
||||
{ title: "Faturalar", url: "/invoices", group: "Finans", icon: Mail },
|
||||
|
||||
// Apps (legacy template)
|
||||
{ title: "Mail", url: "/mail", group: "Apps", icon: Mail },
|
||||
{ title: "Tasks", url: "/tasks", group: "Apps", icon: CheckSquare },
|
||||
{ title: "Chat", url: "/chat", group: "Apps", icon: MessageCircle },
|
||||
{ title: "Calendar", url: "/calendar", group: "Apps", icon: Calendar },
|
||||
|
||||
// Auth Pages
|
||||
{ title: "Sign In 1", url: "/auth/sign-in", group: "Auth Pages", icon: Shield },
|
||||
{ title: "Sign In 2", url: "/auth/sign-in-2", group: "Auth Pages", icon: Shield },
|
||||
{ title: "Sign Up 1", url: "/auth/sign-up", group: "Auth Pages", icon: Shield },
|
||||
{ title: "Sign Up 2", url: "/auth/sign-up-2", group: "Auth Pages", icon: Shield },
|
||||
{ title: "Forgot Password 1", url: "/auth/forgot-password", group: "Auth Pages", icon: Shield },
|
||||
{ title: "Forgot Password 2", url: "/auth/forgot-password-2", group: "Auth Pages", icon: Shield },
|
||||
|
||||
// Errors
|
||||
{ title: "Unauthorized", url: "/errors/unauthorized", group: "Errors", icon: AlertTriangle },
|
||||
{ title: "Forbidden", url: "/errors/forbidden", group: "Errors", icon: AlertTriangle },
|
||||
{ title: "Not Found", url: "/errors/not-found", group: "Errors", icon: AlertTriangle },
|
||||
{ title: "Internal Server Error", url: "/errors/internal-server-error", group: "Errors", icon: AlertTriangle },
|
||||
{ title: "Under Maintenance", url: "/errors/under-maintenance", group: "Errors", icon: AlertTriangle },
|
||||
|
||||
// Settings
|
||||
{ title: "User Settings", url: "/settings/user", group: "Settings", icon: User },
|
||||
{ title: "Account Settings", url: "/settings/account", group: "Settings", icon: Settings },
|
||||
{ title: "Plans & Billing", url: "/settings/billing", group: "Settings", icon: CreditCard },
|
||||
{ title: "Appearance", url: "/settings/appearance", group: "Settings", icon: Palette },
|
||||
{ title: "Notifications", url: "/settings/notifications", group: "Settings", icon: Bell },
|
||||
{ title: "Connections", url: "/settings/connections", group: "Settings", icon: Link2 },
|
||||
|
||||
// Pages
|
||||
{ title: "FAQs", url: "/faqs", group: "Pages", icon: HelpCircle },
|
||||
{ title: "Pricing", url: "/pricing", group: "Pages", icon: CreditCard },
|
||||
]
|
||||
|
||||
const groupedItems = searchItems.reduce((acc, item) => {
|
||||
if (!acc[item.group]) {
|
||||
acc[item.group] = []
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery("");
|
||||
setResults(null);
|
||||
setLoading(false);
|
||||
}
|
||||
acc[item.group].push(item)
|
||||
return acc
|
||||
}, {} as Record<string, SearchItem[]>)
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length < 2) {
|
||||
setResults(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const r = await globalSearchAction(trimmed);
|
||||
setResults(r);
|
||||
} catch {
|
||||
setResults(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 220);
|
||||
return () => clearTimeout(t);
|
||||
}, [query]);
|
||||
|
||||
const handleSelect = (url: string) => {
|
||||
router.push(url)
|
||||
onOpenChange(false)
|
||||
// Bounce effect like Vercel
|
||||
if (commandRef.current) {
|
||||
commandRef.current.style.transform = 'scale(0.96)'
|
||||
setTimeout(() => {
|
||||
if (commandRef.current) {
|
||||
commandRef.current.style.transform = ''
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
router.push(url);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const navMatches = filterNav(NAV, query);
|
||||
const quickMatches = filterNav(QUICK_ACTIONS, query);
|
||||
const settingsMatches = filterNav(SETTINGS_NAV, query);
|
||||
|
||||
const totalEntityHits = results
|
||||
? GROUP_ORDER.reduce((s, g) => s + results[g].length, 0)
|
||||
: 0;
|
||||
|
||||
const showEmpty =
|
||||
query.trim().length >= 2 &&
|
||||
!loading &&
|
||||
totalEntityHits === 0 &&
|
||||
navMatches.length === 0 &&
|
||||
quickMatches.length === 0 &&
|
||||
settingsMatches.length === 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-2xl border border-zinc-200 dark:border-zinc-800 max-w-[640px]">
|
||||
<DialogTitle className="sr-only">Command Search</DialogTitle>
|
||||
<Command
|
||||
ref={commandRef}
|
||||
className="transition-transform duration-100 ease-out"
|
||||
>
|
||||
<CommandInput placeholder="What do you need?" autoFocus />
|
||||
<DialogContent className="max-w-[640px] overflow-hidden border p-0 shadow-2xl">
|
||||
<DialogTitle className="sr-only">Arama</DialogTitle>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Arayın: müşteri, fatura, görev, etkinlik, hizmet..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
autoFocus
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
{Object.entries(groupedItems).map(([group, items]) => (
|
||||
<CommandGroup key={group} heading={group}>
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon
|
||||
{loading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center py-6 text-sm">
|
||||
<Loader2 className="mr-2 size-3.5 animate-spin" />
|
||||
Aranıyor...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEmpty && <CommandEmpty>Sonuç bulunamadı.</CommandEmpty>}
|
||||
|
||||
{results &&
|
||||
!loading &&
|
||||
GROUP_ORDER.map((group) => {
|
||||
const hits = results[group];
|
||||
if (hits.length === 0) return null;
|
||||
const Icon = GROUP_ICON[group];
|
||||
return (
|
||||
<CommandGroup key={group} heading={GROUP_LABEL[group]}>
|
||||
{hits.map((h: SearchHit) => (
|
||||
<CommandItem
|
||||
key={`${group}-${h.id}`}
|
||||
value={`${group}-${h.id}-${h.title}`}
|
||||
onSelect={() => handleSelect(h.url)}
|
||||
>
|
||||
<Icon className="text-muted-foreground size-4" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate">{h.title}</span>
|
||||
{h.subtitle && (
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{h.subtitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
);
|
||||
})}
|
||||
|
||||
{navMatches.length > 0 && (
|
||||
<CommandGroup heading="Sayfalar">
|
||||
{navMatches.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<CommandItem
|
||||
key={item.url}
|
||||
value={item.title}
|
||||
key={item.key}
|
||||
value={item.key}
|
||||
onSelect={() => handleSelect(item.url)}
|
||||
>
|
||||
{Icon && <Icon className="mr-2 h-4 w-4" />}
|
||||
<Icon className="text-muted-foreground size-4" />
|
||||
{item.title}
|
||||
</CommandItem>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
))}
|
||||
)}
|
||||
|
||||
{quickMatches.length > 0 && (
|
||||
<CommandGroup heading="Hızlı aksiyonlar">
|
||||
{quickMatches.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<CommandItem
|
||||
key={item.key}
|
||||
value={item.key}
|
||||
onSelect={() => handleSelect(item.url)}
|
||||
>
|
||||
<Icon className="text-muted-foreground size-4" />
|
||||
{item.title}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{settingsMatches.length > 0 && (
|
||||
<CommandGroup heading="Ayarlar">
|
||||
{settingsMatches.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<CommandItem
|
||||
key={item.key}
|
||||
value={item.key}
|
||||
onSelect={() => handleSelect(item.url)}
|
||||
>
|
||||
<Icon className="text-muted-foreground size-4" />
|
||||
{item.title}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
|
||||
<div className="text-muted-foreground bg-muted/30 flex items-center justify-between gap-2 border-t px-4 py-2 text-[11px]">
|
||||
<span>↵ Aç · ↑↓ Gez · Esc Kapat</span>
|
||||
<kbd className="bg-background rounded border px-1.5 py-0.5 font-mono">⌘K</kbd>
|
||||
</div>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchTrigger({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground h-8 px-3 py-1 relative w-full justify-start text-muted-foreground sm:pr-12 md:w-36 lg:w-56"
|
||||
className="border-input bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring relative inline-flex h-8 w-full items-center justify-start gap-2 whitespace-nowrap rounded-md border px-3 py-1 text-sm font-medium shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50 sm:pr-12 md:w-44 lg:w-64"
|
||||
>
|
||||
<Search className="mr-2 h-3.5 w-3.5" />
|
||||
<span className="hidden lg:inline-flex">Search...</span>
|
||||
<span className="inline-flex lg:hidden">Search...</span>
|
||||
<kbd className="pointer-events-none absolute right-1.5 top-1.5 hidden h-4 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
|
||||
<Search className="mr-1 h-3.5 w-3.5" />
|
||||
<span>Hızlı ara...</span>
|
||||
<kbd className="bg-muted pointer-events-none absolute right-1.5 top-1.5 hidden h-4 select-none items-center gap-1 rounded border px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
|
||||
<span className="text-xs">⌘</span>K
|
||||
</kbd>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"use server";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { createAdminClient } from "./server";
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
type CalendarEvent,
|
||||
type Customer,
|
||||
type FinanceEntry,
|
||||
type Invoice,
|
||||
type Service,
|
||||
type Software,
|
||||
type Task,
|
||||
} from "./schema";
|
||||
import { requireTenant } from "./tenant-guard";
|
||||
|
||||
export type SearchHit = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
url: string;
|
||||
group: SearchGroup;
|
||||
};
|
||||
|
||||
export type SearchGroup =
|
||||
| "customers"
|
||||
| "invoices"
|
||||
| "tasks"
|
||||
| "services"
|
||||
| "software"
|
||||
| "events"
|
||||
| "finance";
|
||||
|
||||
export type SearchResults = Record<SearchGroup, SearchHit[]>;
|
||||
|
||||
const PAGE_LIMIT = 200; // per entity, plenty for search-time scan
|
||||
const MAX_HITS_PER_GROUP = 8;
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
income: "Gelir",
|
||||
expense: "Gider",
|
||||
debt: "Borç",
|
||||
receivable: "Alacak",
|
||||
};
|
||||
|
||||
function tryMatch(haystack: string | undefined | null, needle: string): boolean {
|
||||
if (!haystack) return false;
|
||||
return haystack.toLocaleLowerCase("tr-TR").includes(needle);
|
||||
}
|
||||
|
||||
export async function globalSearchAction(rawQuery: string): Promise<SearchResults> {
|
||||
const empty: SearchResults = {
|
||||
customers: [],
|
||||
invoices: [],
|
||||
tasks: [],
|
||||
services: [],
|
||||
software: [],
|
||||
events: [],
|
||||
finance: [],
|
||||
};
|
||||
|
||||
const q = rawQuery.trim().toLocaleLowerCase("tr-TR");
|
||||
if (!q || q.length < 2) return empty;
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
|
||||
const { tablesDB } = createAdminClient();
|
||||
const tenantQ = [Query.equal("tenantId", ctx.tenantId), Query.limit(PAGE_LIMIT)];
|
||||
|
||||
const [customers, invoices, tasks, services, software, events, finance] =
|
||||
await Promise.all([
|
||||
tablesDB
|
||||
.listRows({ databaseId: DATABASE_ID, tableId: TABLES.customers, queries: tenantQ })
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
tablesDB
|
||||
.listRows({ databaseId: DATABASE_ID, tableId: TABLES.invoices, queries: tenantQ })
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
tablesDB
|
||||
.listRows({ databaseId: DATABASE_ID, tableId: TABLES.tasks, queries: tenantQ })
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
tablesDB
|
||||
.listRows({ databaseId: DATABASE_ID, tableId: TABLES.services, queries: tenantQ })
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
tablesDB
|
||||
.listRows({ databaseId: DATABASE_ID, tableId: TABLES.software, queries: tenantQ })
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
tablesDB
|
||||
.listRows({ databaseId: DATABASE_ID, tableId: TABLES.calendarEvents, queries: tenantQ })
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
tablesDB
|
||||
.listRows({ databaseId: DATABASE_ID, tableId: TABLES.financeEntries, queries: tenantQ })
|
||||
.catch(() => ({ rows: [] as unknown[] })),
|
||||
]);
|
||||
|
||||
const customerMap = new Map<string, string>();
|
||||
for (const c of customers.rows as unknown as Customer[]) {
|
||||
customerMap.set(c.$id, c.name);
|
||||
}
|
||||
|
||||
// ---------- Customers ----------
|
||||
const customerHits: SearchHit[] = [];
|
||||
for (const c of customers.rows as unknown as Customer[]) {
|
||||
if (
|
||||
tryMatch(c.name, q) ||
|
||||
tryMatch(c.email, q) ||
|
||||
tryMatch(c.phone, q) ||
|
||||
tryMatch(c.taxId, q)
|
||||
) {
|
||||
customerHits.push({
|
||||
id: c.$id,
|
||||
title: c.name,
|
||||
subtitle: c.email || c.phone || c.taxId || undefined,
|
||||
url: "/customers",
|
||||
group: "customers",
|
||||
});
|
||||
if (customerHits.length >= MAX_HITS_PER_GROUP) break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Invoices ----------
|
||||
const invoiceHits: SearchHit[] = [];
|
||||
for (const inv of invoices.rows as unknown as Invoice[]) {
|
||||
if (
|
||||
tryMatch(inv.number, q) ||
|
||||
tryMatch(inv.notes, q) ||
|
||||
tryMatch(customerMap.get(inv.customerId), q)
|
||||
) {
|
||||
invoiceHits.push({
|
||||
id: inv.$id,
|
||||
title: inv.number,
|
||||
subtitle: customerMap.get(inv.customerId) ?? undefined,
|
||||
url: `/invoices/${inv.$id}`,
|
||||
group: "invoices",
|
||||
});
|
||||
if (invoiceHits.length >= MAX_HITS_PER_GROUP) break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Tasks ----------
|
||||
const taskHits: SearchHit[] = [];
|
||||
for (const t of tasks.rows as unknown as Task[]) {
|
||||
if (tryMatch(t.title, q) || tryMatch(t.description, q)) {
|
||||
taskHits.push({
|
||||
id: t.$id,
|
||||
title: t.title,
|
||||
subtitle: t.description ? t.description.slice(0, 80) : undefined,
|
||||
url: "/tasks",
|
||||
group: "tasks",
|
||||
});
|
||||
if (taskHits.length >= MAX_HITS_PER_GROUP) break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Services ----------
|
||||
const serviceHits: SearchHit[] = [];
|
||||
for (const s of services.rows as unknown as Service[]) {
|
||||
if (tryMatch(s.name, q) || tryMatch(s.description, q)) {
|
||||
serviceHits.push({
|
||||
id: s.$id,
|
||||
title: s.name,
|
||||
subtitle: customerMap.get(s.customerId) ?? undefined,
|
||||
url: "/services",
|
||||
group: "services",
|
||||
});
|
||||
if (serviceHits.length >= MAX_HITS_PER_GROUP) break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Software ----------
|
||||
const softwareHits: SearchHit[] = [];
|
||||
for (const s of software.rows as unknown as Software[]) {
|
||||
if (tryMatch(s.name, q) || tryMatch(s.version, q) || tryMatch(s.description, q)) {
|
||||
softwareHits.push({
|
||||
id: s.$id,
|
||||
title: s.name,
|
||||
subtitle: s.version ? `v${s.version}` : undefined,
|
||||
url: "/software",
|
||||
group: "software",
|
||||
});
|
||||
if (softwareHits.length >= MAX_HITS_PER_GROUP) break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Calendar events ----------
|
||||
const eventHits: SearchHit[] = [];
|
||||
for (const e of events.rows as unknown as CalendarEvent[]) {
|
||||
if (tryMatch(e.title, q) || tryMatch(e.description, q)) {
|
||||
eventHits.push({
|
||||
id: e.$id,
|
||||
title: e.title,
|
||||
subtitle: new Date(e.start).toLocaleDateString("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}),
|
||||
url: "/calendar",
|
||||
group: "events",
|
||||
});
|
||||
if (eventHits.length >= MAX_HITS_PER_GROUP) break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Finance entries ----------
|
||||
const financeHits: SearchHit[] = [];
|
||||
for (const e of finance.rows as unknown as FinanceEntry[]) {
|
||||
const amountStr = e.amount.toString();
|
||||
if (
|
||||
tryMatch(e.description, q) ||
|
||||
tryMatch(customerMap.get(e.customerId ?? ""), q) ||
|
||||
tryMatch(amountStr, q)
|
||||
) {
|
||||
financeHits.push({
|
||||
id: e.$id,
|
||||
title: `${TYPE_LABEL[e.type]} — ${e.amount.toLocaleString("tr-TR", { minimumFractionDigits: 2 })} ₺`,
|
||||
subtitle:
|
||||
(customerMap.get(e.customerId ?? "") || e.description || "").slice(0, 80) ||
|
||||
undefined,
|
||||
url: "/finance",
|
||||
group: "finance",
|
||||
});
|
||||
if (financeHits.length >= MAX_HITS_PER_GROUP) break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
customers: customerHits,
|
||||
invoices: invoiceHits,
|
||||
tasks: taskHits,
|
||||
services: serviceHits,
|
||||
software: softwareHits,
|
||||
events: eventHits,
|
||||
finance: financeHits,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user