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
+273 -151
View File
@@ -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) => (
<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
)}
{...props}
/>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
<div className="flex items-center border-b px-4">
<Search className="text-muted-foreground mr-2 size-4" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"placeholder:text-muted-foreground flex h-12 w-full border-none bg-transparent py-3 text-base outline-none",
className,
)}
{...props}
/>
</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> &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>
</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>
)
);
}
+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,
};
}