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 * as React from "react";
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation";
|
||||||
import { Command as CommandPrimitive } from "cmdk"
|
import { Command as CommandPrimitive } from "cmdk";
|
||||||
import {
|
import {
|
||||||
Search,
|
Briefcase,
|
||||||
LayoutDashboard,
|
Calendar as CalendarIcon,
|
||||||
Mail,
|
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
MessageCircle,
|
CircleDollarSign,
|
||||||
Calendar,
|
FilePlus,
|
||||||
Shield,
|
LayoutDashboard,
|
||||||
AlertTriangle,
|
Loader2,
|
||||||
|
Package,
|
||||||
|
Receipt,
|
||||||
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
HelpCircle,
|
UserPlus,
|
||||||
CreditCard,
|
|
||||||
User,
|
|
||||||
Users,
|
Users,
|
||||||
Bell,
|
Wallet,
|
||||||
Link2,
|
|
||||||
Palette,
|
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react";
|
||||||
|
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
globalSearchAction,
|
||||||
|
type SearchGroup,
|
||||||
|
type SearchHit,
|
||||||
|
type SearchResults,
|
||||||
|
} from "@/lib/appwrite/search-actions";
|
||||||
|
|
||||||
const Command = React.forwardRef<
|
const Command = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive>,
|
React.ElementRef<typeof CommandPrimitive>,
|
||||||
@@ -33,28 +37,32 @@ const Command = React.forwardRef<
|
|||||||
<CommandPrimitive
|
<CommandPrimitive
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-xl",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
shouldFilter={false}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
Command.displayName = CommandPrimitive.displayName
|
Command.displayName = CommandPrimitive.displayName;
|
||||||
|
|
||||||
const CommandInput = React.forwardRef<
|
const CommandInput = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="flex items-center border-b px-4">
|
||||||
|
<Search className="text-muted-foreground mr-2 size-4" />
|
||||||
<CommandPrimitive.Input
|
<CommandPrimitive.Input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"placeholder:text-muted-foreground flex h-12 w-full border-none bg-transparent py-3 text-base outline-none",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
</div>
|
||||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
));
|
||||||
|
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||||
|
|
||||||
const CommandList = React.forwardRef<
|
const CommandList = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive.List>,
|
React.ElementRef<typeof CommandPrimitive.List>,
|
||||||
@@ -62,11 +70,11 @@ const CommandList = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<CommandPrimitive.List
|
<CommandPrimitive.List
|
||||||
ref={ref}
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
CommandList.displayName = CommandPrimitive.List.displayName
|
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||||
|
|
||||||
const CommandEmpty = React.forwardRef<
|
const CommandEmpty = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||||
@@ -74,11 +82,11 @@ const CommandEmpty = React.forwardRef<
|
|||||||
>((props, ref) => (
|
>((props, ref) => (
|
||||||
<CommandPrimitive.Empty
|
<CommandPrimitive.Empty
|
||||||
ref={ref}
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||||
|
|
||||||
const CommandGroup = React.forwardRef<
|
const CommandGroup = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||||
@@ -87,13 +95,13 @@ const CommandGroup = React.forwardRef<
|
|||||||
<CommandPrimitive.Group
|
<CommandPrimitive.Group
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||||
|
|
||||||
const CommandItem = React.forwardRef<
|
const CommandItem = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||||
@@ -102,150 +110,264 @@ const CommandItem = React.forwardRef<
|
|||||||
<CommandPrimitive.Item
|
<CommandPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||||
|
|
||||||
interface SearchItem {
|
type NavItem = { key: string; title: string; url: string; icon: LucideIcon };
|
||||||
title: string
|
|
||||||
url: string
|
const NAV: NavItem[] = [
|
||||||
group: string
|
{ key: "nav-dashboard", title: "Genel bakış", url: "/dashboard", icon: LayoutDashboard },
|
||||||
icon?: LucideIcon
|
{ 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 {
|
interface CommandSearchProps {
|
||||||
open: boolean
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void
|
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) {
|
export function CommandSearch({ open, onOpenChange }: CommandSearchProps) {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const commandRef = React.useRef<HTMLDivElement>(null)
|
const [query, setQuery] = React.useState("");
|
||||||
|
const [results, setResults] = React.useState<SearchResults | null>(null);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
|
||||||
const searchItems: SearchItem[] = [
|
React.useEffect(() => {
|
||||||
// Genel
|
if (!open) {
|
||||||
{ title: "Genel bakış", url: "/dashboard", group: "Genel", icon: LayoutDashboard },
|
setQuery("");
|
||||||
|
setResults(null);
|
||||||
// İşletme
|
setLoading(false);
|
||||||
{ 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] = []
|
|
||||||
}
|
}
|
||||||
acc[item.group].push(item)
|
}, [open]);
|
||||||
return acc
|
|
||||||
}, {} as Record<string, SearchItem[]>)
|
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) => {
|
const handleSelect = (url: string) => {
|
||||||
router.push(url)
|
router.push(url);
|
||||||
onOpenChange(false)
|
onOpenChange(false);
|
||||||
// Bounce effect like Vercel
|
};
|
||||||
if (commandRef.current) {
|
|
||||||
commandRef.current.style.transform = 'scale(0.96)'
|
const navMatches = filterNav(NAV, query);
|
||||||
setTimeout(() => {
|
const quickMatches = filterNav(QUICK_ACTIONS, query);
|
||||||
if (commandRef.current) {
|
const settingsMatches = filterNav(SETTINGS_NAV, query);
|
||||||
commandRef.current.style.transform = ''
|
|
||||||
}
|
const totalEntityHits = results
|
||||||
}, 100)
|
? 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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="overflow-hidden p-0 shadow-2xl border border-zinc-200 dark:border-zinc-800 max-w-[640px]">
|
<DialogContent className="max-w-[640px] overflow-hidden border p-0 shadow-2xl">
|
||||||
<DialogTitle className="sr-only">Command Search</DialogTitle>
|
<DialogTitle className="sr-only">Arama</DialogTitle>
|
||||||
<Command
|
<Command>
|
||||||
ref={commandRef}
|
<CommandInput
|
||||||
className="transition-transform duration-100 ease-out"
|
placeholder="Arayın: müşteri, fatura, görev, etkinlik, hizmet..."
|
||||||
>
|
value={query}
|
||||||
<CommandInput placeholder="What do you need?" autoFocus />
|
onValueChange={setQuery}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>No results found.</CommandEmpty>
|
{loading && (
|
||||||
{Object.entries(groupedItems).map(([group, items]) => (
|
<div className="text-muted-foreground flex items-center justify-center py-6 text-sm">
|
||||||
<CommandGroup key={group} heading={group}>
|
<Loader2 className="mr-2 size-3.5 animate-spin" />
|
||||||
{items.map((item) => {
|
Aranıyor...
|
||||||
const Icon = item.icon
|
</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 (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={item.url}
|
key={item.key}
|
||||||
value={item.title}
|
value={item.key}
|
||||||
onSelect={() => handleSelect(item.url)}
|
onSelect={() => handleSelect(item.url)}
|
||||||
>
|
>
|
||||||
{Icon && <Icon className="mr-2 h-4 w-4" />}
|
<Icon className="text-muted-foreground size-4" />
|
||||||
{item.title}
|
{item.title}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</CommandGroup>
|
</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>
|
</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>
|
</Command>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchTrigger({ onClick }: { onClick: () => void }) {
|
export function SearchTrigger({ onClick }: { onClick: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
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" />
|
<Search className="mr-1 h-3.5 w-3.5" />
|
||||||
<span className="hidden lg:inline-flex">Search...</span>
|
<span>Hızlı ara...</span>
|
||||||
<span className="inline-flex lg:hidden">Search...</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">
|
||||||
<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">
|
|
||||||
<span className="text-xs">⌘</span>K
|
<span className="text-xs">⌘</span>K
|
||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</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