192 lines
6.7 KiB
TypeScript
192 lines
6.7 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import {
|
|
LayoutDashboard,
|
|
Users,
|
|
Radar,
|
|
FolderKanban,
|
|
KeyRound,
|
|
Receipt,
|
|
Settings2,
|
|
RotateCcw,
|
|
ScrollText,
|
|
Search as SearchIcon,
|
|
ArrowRight,
|
|
FileSignature,
|
|
Wallet,
|
|
FileText,
|
|
LifeBuoy,
|
|
BarChart3,
|
|
Rocket,
|
|
BookOpen,
|
|
Webhook,
|
|
HelpCircle,
|
|
SlidersHorizontal,
|
|
CalendarDays,
|
|
} from 'lucide-react'
|
|
import { useAos, type ViewId } from '@/store/aos'
|
|
import {
|
|
CommandDialog,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
CommandSeparator,
|
|
} from '@/components/ui/command'
|
|
import { toast } from 'sonner'
|
|
|
|
const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: string }[] = [
|
|
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, hint: 'Übersicht & KPIs' },
|
|
{ id: 'calendar', label: 'Fristen', icon: CalendarDays, hint: 'Kalender & Deadlines' },
|
|
{ id: 'reporting', label: 'Reporting', icon: BarChart3, hint: 'Pipeline, Auslastung, MRR' },
|
|
{ id: 'crm', label: 'CRM', icon: Users, hint: 'Firmen & Pipeline' },
|
|
{ id: 'leads', label: 'Leads Finder', icon: Radar, hint: 'Review-Queue' },
|
|
{ id: 'estimates', label: 'Angebote', icon: FileText, hint: 'Quotes & Conversion' },
|
|
{ id: 'projects', label: 'Projekte', icon: FolderKanban, hint: 'Aufgaben & Zeit' },
|
|
{ id: 'support', label: 'Support', icon: LifeBuoy, hint: 'Tickets & SLA' },
|
|
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, hint: 'Ausgestellt & Erneuerungen' },
|
|
{ id: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' },
|
|
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' },
|
|
{ id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' },
|
|
{ id: 'accounting', label: 'Buchhaltung', icon: BookOpen, hint: 'USt-Voranmeldung, EÜR, Bank' },
|
|
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, hint: 'Hash-Kette & GoBD' },
|
|
{ id: 'kb', label: 'Wissensdatenbank', icon: HelpCircle, hint: 'Dokumente & Vorlagen' },
|
|
{ id: 'api', label: 'API & Webhooks', icon: Webhook, hint: 'Tokens, Scopes, HMAC' },
|
|
{ id: 'settings', label: 'Erweiterungen', icon: Settings2, hint: 'Module & Compliance' },
|
|
{ id: 'settings_global', label: 'Einstellungen', icon: SlidersHorizontal, hint: 'Stammdaten, Steuern, i18n' },
|
|
{ id: 'setup', label: 'Setup-Wizard', icon: Rocket, hint: '≤ 15 Min Installation' },
|
|
]
|
|
|
|
interface SearchResult {
|
|
group: string
|
|
view: ViewId
|
|
id: string
|
|
primary: string
|
|
secondary?: string
|
|
tertiary?: string
|
|
}
|
|
|
|
export function CommandMenu() {
|
|
const { commandOpen, setCommandOpen, setView, setSelectedCompany } = useAos()
|
|
const router = useRouter()
|
|
const [query, setQuery] = useState('')
|
|
const [results, setResults] = useState<SearchResult[]>([])
|
|
const [searching, setSearching] = useState(false)
|
|
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
e.preventDefault()
|
|
setCommandOpen(!commandOpen)
|
|
}
|
|
}
|
|
window.addEventListener('keydown', handler)
|
|
return () => window.removeEventListener('keydown', handler)
|
|
}, [commandOpen, setCommandOpen])
|
|
|
|
// debounced global search
|
|
useEffect(() => {
|
|
if (!commandOpen) {
|
|
setQuery('')
|
|
setResults([])
|
|
return
|
|
}
|
|
const q = query.trim()
|
|
if (q.length < 2) {
|
|
setResults([])
|
|
return
|
|
}
|
|
setSearching(true)
|
|
const t = setTimeout(async () => {
|
|
try {
|
|
const r = await fetch(`/api/aos/search?q=${encodeURIComponent(q)}`)
|
|
if (!r.ok) throw new Error('search failed')
|
|
const data = (await r.json()) as { results: SearchResult[] }
|
|
setResults(data.results)
|
|
} catch {
|
|
setResults([])
|
|
} finally {
|
|
setSearching(false)
|
|
}
|
|
}, 200)
|
|
return () => clearTimeout(t)
|
|
}, [query, commandOpen])
|
|
|
|
const go = (v: ViewId) => {
|
|
setView(v)
|
|
setCommandOpen(false)
|
|
}
|
|
|
|
const goResult = (r: SearchResult) => {
|
|
if (r.view === 'crm' && r.group === 'Firmen') {
|
|
setSelectedCompany(r.id)
|
|
}
|
|
setView(r.view)
|
|
setCommandOpen(false)
|
|
}
|
|
|
|
const reseed = async () => {
|
|
setCommandOpen(false)
|
|
const t = toast.loading('Datenbank wird zurückgesetzt…')
|
|
try {
|
|
const r = await fetch('/api/aos/seed', { method: 'POST' })
|
|
if (!r.ok) throw new Error('reseed failed')
|
|
toast.success('Datenbank neu befüllt', { id: t })
|
|
setTimeout(() => router.refresh(), 400)
|
|
} catch {
|
|
toast.error('Reseed fehlgeschlagen', { id: t })
|
|
}
|
|
}
|
|
|
|
return (
|
|
<CommandDialog open={commandOpen} onOpenChange={setCommandOpen}>
|
|
<CommandInput placeholder="Suche Firmen, Deals, Leads, Projekte, Rechnungen…" value={query} onValueChange={setQuery} />
|
|
<CommandList>
|
|
<CommandEmpty>{searching ? 'Suche läuft…' : query.length > 1 ? 'Keine Treffer.' : 'Tippe mindestens 2 Zeichen für globale Suche.'}</CommandEmpty>
|
|
|
|
{results.length > 0 && (
|
|
<>
|
|
<CommandGroup heading={`Suchergebnisse (${results.length})`}>
|
|
{results.slice(0, 12).map((r) => (
|
|
<CommandItem key={`${r.group}-${r.id}`} value={`${r.primary} ${r.secondary ?? ''} ${r.tertiary ?? ''}`} onSelect={() => goResult(r)}>
|
|
<SearchIcon className="size-4 text-muted-foreground" />
|
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
<span className="truncate">{r.primary}</span>
|
|
{r.secondary && <span className="truncate text-xs text-muted-foreground">· {r.secondary}</span>}
|
|
</div>
|
|
<span className="shrink-0 text-[10px] text-muted-foreground">{r.group}</span>
|
|
<ArrowRight className="size-3 shrink-0 text-muted-foreground" />
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
<CommandSeparator />
|
|
</>
|
|
)}
|
|
|
|
<CommandGroup heading="Navigation">
|
|
{VIEWS.map((v) => {
|
|
const Icon = v.icon
|
|
return (
|
|
<CommandItem key={v.id} value={`${v.label} ${v.hint}`} onSelect={() => go(v.id)}>
|
|
<Icon className="size-4" />
|
|
<span>{v.label}</span>
|
|
<span className="ml-auto text-xs text-muted-foreground">{v.hint}</span>
|
|
</CommandItem>
|
|
)
|
|
})}
|
|
</CommandGroup>
|
|
<CommandSeparator />
|
|
<CommandGroup heading="Aktionen">
|
|
<CommandItem onSelect={reseed} value="demo daten zurücksetzen reseed reset">
|
|
<RotateCcw className="size-4" />
|
|
<span>Demo-Daten zurücksetzen</span>
|
|
</CommandItem>
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</CommandDialog>
|
|
)
|
|
}
|