189 lines
8.0 KiB
TypeScript
189 lines
8.0 KiB
TypeScript
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<JobSearchItem[]>([]);
|
|
const [companies, setCompanies] = useState<CompanySearchItem[]>([]);
|
|
const [searchState, setSearchState] = useState<"idle" | "loading" | "ready" | "error">("idle");
|
|
const [searchAttempt, setSearchAttempt] = useState(0);
|
|
const [activeIndex, setActiveIndex] = useState(-1);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
const resultRefs = useRef<Array<HTMLElement | null>>([]);
|
|
|
|
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<CompanySearchItem[]>("/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<CommandItem[]>(() => {
|
|
const base: CommandItem[] = [
|
|
{ id: "go-dashboard", label: t("goToDashboard"), hint: t("dashboardPageSubtitle"), 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 (
|
|
<Dialog open={open} onClose={onClose} slotProps={{ paper: { "aria-label": t("quickSearch") }, transition: { onEntered: () => searchInputRef.current?.focus() } }} fullWidth maxWidth="sm">
|
|
<DialogContent onKeyDown={onKeys} sx={{ p: 1.5, maxHeight: "min(70vh, 620px)" }}>
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, px: 1, pt: 0.5 }}>
|
|
<SearchIcon fontSize="small" />
|
|
<TextField
|
|
autoFocus
|
|
inputRef={searchInputRef}
|
|
variant="standard"
|
|
fullWidth
|
|
placeholder={t("searchPlaceholder")}
|
|
slotProps={{ htmlInput: { "aria-label": t("quickSearch") } }}
|
|
value={query}
|
|
onChange={(e) => { setQuery(e.target.value); setActiveIndex(-1); }}
|
|
InputProps={{ disableUnderline: true }}
|
|
/>
|
|
<Typography variant="caption" sx={{ color: "text.secondary", whiteSpace: "nowrap" }}>
|
|
Ctrl/Cmd + K
|
|
</Typography>
|
|
</Box>
|
|
|
|
<Box aria-live="polite" sx={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
|
|
{searchState === "loading" ? t("loading") : searchState === "ready" ? t("searchResultsCount", { count: allItems.length }) : ""}
|
|
{activeIndex >= 0 ? ` ${allItems[activeIndex]?.label ?? ""}` : ""}
|
|
</Box>
|
|
{searchState === "error" ? <Alert severity="error" sx={{ mt: 1 }} action={<Button color="inherit" size="small" onClick={() => setSearchAttempt((value) => value + 1)}>{t("retry")}</Button>}>{t("searchLoadFailed")}</Alert> : null}
|
|
{groups.map((group) => group.items.length ? <Box component="section" key={group.label} sx={{ mt: 1 }}>
|
|
<Typography component="h2" variant="caption" color="text.secondary" sx={{ px: 2, fontWeight: 700 }}>{group.label}</Typography>
|
|
<List dense aria-label={group.label}>
|
|
{group.items.map((item) => <ListItem key={item.id} disablePadding>
|
|
<ListItemButton ref={(node) => { resultRefs.current[allItems.indexOf(item)] = node; }} selected={activeIndex === allItems.indexOf(item)} onFocus={() => setActiveIndex(allItems.indexOf(item))} onClick={() => activate(item)} sx={{ borderRadius: 2 }}>
|
|
<ListItemText primary={item.label} secondary={item.hint} />
|
|
</ListItemButton>
|
|
</ListItem>)}
|
|
</List>
|
|
</Box> : null)}
|
|
{allItems.length === 0 && searchState !== "loading" && searchState !== "error" ? <Typography color="text.secondary" sx={{ px: 2, py: 3 }}>{t("noMatchingCommands")}</Typography> : null}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|