diff --git a/src/components/command-search.tsx b/src/components/command-search.tsx index c4c7ceb..d20aacf 100644 --- a/src/components/command-search.tsx +++ b/src/components/command-search.tsx @@ -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, @@ -33,28 +37,32 @@ const Command = React.forwardRef< -)) -Command.displayName = CommandPrimitive.displayName +)); +Command.displayName = CommandPrimitive.displayName; const CommandInput = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - -)) -CommandInput.displayName = CommandPrimitive.Input.displayName +
+ + +
+)); +CommandInput.displayName = CommandPrimitive.Input.displayName; const CommandList = React.forwardRef< React.ElementRef, @@ -62,11 +70,11 @@ const CommandList = React.forwardRef< >(({ className, ...props }, ref) => ( -)) -CommandList.displayName = CommandPrimitive.List.displayName +)); +CommandList.displayName = CommandPrimitive.List.displayName; const CommandEmpty = React.forwardRef< React.ElementRef, @@ -74,11 +82,11 @@ const CommandEmpty = React.forwardRef< >((props, ref) => ( -)) -CommandEmpty.displayName = CommandPrimitive.Empty.displayName +)); +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; const CommandGroup = React.forwardRef< React.ElementRef, @@ -87,13 +95,13 @@ const CommandGroup = React.forwardRef< -)) -CommandGroup.displayName = CommandPrimitive.Group.displayName +)); +CommandGroup.displayName = CommandPrimitive.Group.displayName; const CommandItem = React.forwardRef< React.ElementRef, @@ -102,150 +110,264 @@ const CommandItem = React.forwardRef< -)) -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 = { + customers: "Müşteriler", + invoices: "Faturalar", + tasks: "Görevler", + services: "Hizmetler", + software: "Yazılımlar", + events: "Takvim", + finance: "Finans", +}; + +const GROUP_ICON: Record = { + 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(null) + const router = useRouter(); + const [query, setQuery] = React.useState(""); + const [results, setResults] = React.useState(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) + }, [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 ( - - Command Search - - + + Arama + + - No results found. - {Object.entries(groupedItems).map(([group, items]) => ( - - {items.map((item) => { - const Icon = item.icon + {loading && ( +
+ + Aranıyor... +
+ )} + + {showEmpty && Sonuç bulunamadı.} + + {results && + !loading && + GROUP_ORDER.map((group) => { + const hits = results[group]; + if (hits.length === 0) return null; + const Icon = GROUP_ICON[group]; + return ( + + {hits.map((h: SearchHit) => ( + handleSelect(h.url)} + > + +
+ {h.title} + {h.subtitle && ( + + {h.subtitle} + + )} +
+
+ ))} +
+ ); + })} + + {navMatches.length > 0 && ( + + {navMatches.map((item) => { + const Icon = item.icon; return ( handleSelect(item.url)} > - {Icon && } + {item.title} - ) + ); })} - ))} + )} + + {quickMatches.length > 0 && ( + + {quickMatches.map((item) => { + const Icon = item.icon; + return ( + handleSelect(item.url)} + > + + {item.title} + + ); + })} + + )} + + {settingsMatches.length > 0 && ( + + {settingsMatches.map((item) => { + const Icon = item.icon; + return ( + handleSelect(item.url)} + > + + {item.title} + + ); + })} + + )}
+ +
+ ↵ Aç  ·  ↑↓ Gez  ·  Esc Kapat + ⌘K +
- ) + ); } export function SearchTrigger({ onClick }: { onClick: () => void }) { return ( - ) + ); } diff --git a/src/lib/appwrite/search-actions.ts b/src/lib/appwrite/search-actions.ts new file mode 100644 index 0000000..3706430 --- /dev/null +++ b/src/lib/appwrite/search-actions.ts @@ -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; + +const PAGE_LIMIT = 200; // per entity, plenty for search-time scan +const MAX_HITS_PER_GROUP = 8; + +const TYPE_LABEL: Record = { + 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 { + 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(); + 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, + }; +}