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:
kovakmedya
2026-04-30 07:08:22 +03:00
parent 89d456fc76
commit c848531326
2 changed files with 514 additions and 151 deletions
+267 -145
View File
@@ -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> &nbsp;·&nbsp; Gez &nbsp;·&nbsp; 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>
) );
} }
+241
View File
@@ -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,
};
}