import React, { useEffect, useMemo, useRef, useState } from "react"; import { Box, Alert, Button, Dialog, DialogContent, List, ListItem, ListItemButton, ListItemText, TextField, Typography, } from "@mui/material"; import SearchIcon from "@mui/icons-material/Search"; import { api } from "../api"; import { useI18n } from "../i18n/I18nProvider"; type CommandItem = { id: string; label: string; hint?: string; action: () => void; }; type JobSearchItem = { id: number; jobTitle: string; company: { name: string }; }; type CompanySearchItem = { id: number; name: string; }; interface Props { open: boolean; onClose: () => void; onNavigate: (to: string) => void; onOpenAddJob: () => void; } export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAddJob }: Props) { const { t } = useI18n(); const [query, setQuery] = useState(""); const [jobs, setJobs] = useState([]); const [companies, setCompanies] = useState([]); const [searchState, setSearchState] = useState<"idle" | "loading" | "ready" | "error">("idle"); const [searchAttempt, setSearchAttempt] = useState(0); const [activeIndex, setActiveIndex] = useState(-1); const searchInputRef = useRef(null); const resultRefs = useRef>([]); useEffect(() => { if (!open) { setQuery(""); setJobs([]); setCompanies([]); setSearchState("idle"); setActiveIndex(-1); return; } }, [open]); useEffect(() => { if (!open) return; const q = query.trim(); if (!q) { setJobs([]); setCompanies([]); setSearchState("idle"); return; } const controller = new AbortController(); setJobs([]); setCompanies([]); setSearchState("loading"); const timeout = window.setTimeout(() => { Promise.all([ api.get<{ items: JobSearchItem[] }>("/jobapplications", { params: { q, page: 1, pageSize: 15 }, signal: controller.signal }), api.get("/companies", { signal: controller.signal }), ]) .then(([jobResponse, companyResponse]) => { if (controller.signal.aborted) return; setJobs(jobResponse.data?.items ?? []); setCompanies((companyResponse.data ?? []).filter((c) => c.name.toLowerCase().includes(q.toLowerCase())).slice(0, 8)); setSearchState("ready"); }) .catch(() => { if (!controller.signal.aborted) setSearchState("error"); }); }, 250); return () => { window.clearTimeout(timeout); controller.abort(); }; }, [open, query, searchAttempt]); const commands = useMemo(() => { const base: CommandItem[] = [ { id: "go-dashboard", label: t("goToDashboard"), hint: t("analyticsOverview"), action: () => onNavigate("/dashboard") }, { id: "go-jobs", label: t("goToJobs"), hint: t("mainApplicationsTable"), action: () => onNavigate("/jobs") }, { id: "go-reminders", label: t("goToReminders"), hint: t("followUpQueue"), action: () => onNavigate("/reminders") }, { id: "go-companies", label: t("goToCompanies"), hint: t("crmAndSourceTracking"), action: () => onNavigate("/companies") }, { id: "go-settings", label: t("goToSettings"), hint: t("preferencesAndAdminTools"), action: () => onNavigate("/settings") }, { id: "add-job", label: t("addNewJob"), hint: t("openAddJobModal"), action: onOpenAddJob }, ]; const q = query.trim().toLowerCase(); if (!q) return base; return base.filter((item) => item.label.toLowerCase().includes(q) || item.hint?.toLowerCase().includes(q)); }, [onNavigate, onOpenAddJob, query, t]); const jobItems: CommandItem[] = jobs.slice(0, 6).map((job) => ({ id: `job-${job.id}`, label: `${job.company?.name ?? t("company")} - ${job.jobTitle}`, hint: t("openJobListAndSearchResult"), action: () => onNavigate(`/jobs/${job.id}`), })); const companyItems: CommandItem[] = companies.slice(0, 6).map((company) => ({ id: `company-${company.id}`, label: company.name, hint: t("openCompanies"), action: () => onNavigate(`/companies?edit=${company.id}`), })); const groups = [ { label: t("commandActions"), items: commands }, { label: t("commandJobs"), items: jobItems }, { label: t("commandCompanies"), items: companyItems }, ]; const allItems = groups.flatMap((group) => group.items); const activate = (item: CommandItem) => { item.action(); onClose(); }; const onKeys = (event: React.KeyboardEvent) => { if (event.nativeEvent.isComposing || event.keyCode === 229 || !allItems.length) return; if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) { event.preventDefault(); const current = event.target === searchInputRef.current ? activeIndex : resultRefs.current.indexOf(event.target as HTMLElement); const next = event.key === "Home" ? 0 : event.key === "End" ? allItems.length - 1 : event.key === "ArrowDown" ? (current + 1) % allItems.length : (current < 0 ? allItems.length - 1 : (current - 1 + allItems.length) % allItems.length); setActiveIndex(next); if (event.target !== searchInputRef.current) resultRefs.current[next]?.focus(); } else if (event.key === "Enter" && event.target === searchInputRef.current) { event.preventDefault(); activate(allItems[activeIndex < 0 ? 0 : activeIndex]); } }; return ( searchInputRef.current?.focus() } }} fullWidth maxWidth="sm"> { setQuery(e.target.value); setActiveIndex(-1); }} InputProps={{ disableUnderline: true }} /> Ctrl/Cmd + K {searchState === "loading" ? t("loading") : searchState === "ready" ? t("searchResultsCount", { count: allItems.length }) : ""} {activeIndex >= 0 ? ` ${allItems[activeIndex]?.label ?? ""}` : ""} {searchState === "error" ? setSearchAttempt((value) => value + 1)}>{t("retry")}}>{t("searchLoadFailed")} : null} {groups.map((group) => group.items.length ? {group.label} {group.items.map((item) => { resultRefs.current[allItems.indexOf(item)] = node; }} selected={activeIndex === allItems.indexOf(item)} onFocus={() => setActiveIndex(allItems.indexOf(item))} onClick={() => activate(item)} sx={{ borderRadius: 2 }}> )} : null)} {allItems.length === 0 && searchState !== "loading" && searchState !== "error" ? {t("noMatchingCommands")} : null} ); }