espace-paie-odentas/components/GlobalSearchOverlay.tsx
2025-10-12 17:05:46 +02:00

633 lines
29 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState, Fragment } from "react";
import { Command } from "cmdk";
import { useQuery } from "@tanstack/react-query";
// Petit hook de debounce pour éviter de spammer /api/search à chaque frappe
function useDebouncedValue<T>(value: T, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
function normalize(str: string) {
return (str || "")
.toLowerCase()
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "");
}
const MONTH_NAME_MAP: Record<string, number> = {
"janvier":1, "fevrier":2, "février":2, "mars":3, "avril":4, "mai":5, "juin":6,
"juillet":7, "aout":8, "août":8, "septembre":9, "octobre":10, "novembre":11, "decembre":12, "décembre":12
};
function pad2(n: number) { return String(n).padStart(2, "0"); }
// Parse "1 mars 2024" ou "1/03/2024" -> { y, m, d }
function parseFRDateParts(token: string, fallbackYear?: number): { y: number, m: number, d: number } | null {
const t = normalize(token);
// Format texte: 1 mars 2024 (année optionnelle)
const rxText = /\b(\d{1,2})\s+(janvier|fevrier|février|mars|avril|mai|juin|juillet|aout|août|septembre|octobre|novembre|decembre|décembre)\s*(20\d{2})?\b/;
const m1 = t.match(rxText);
if (m1) {
const d = parseInt(m1[1], 10);
const m = MONTH_NAME_MAP[m1[2]];
const y = m1[3] ? parseInt(m1[3], 10) : (fallbackYear ?? new Date().getFullYear());
return { y, m, d };
}
// Format numérique: 01/03/2024 ou 1-3-2024
const rxNum = /\b(\d{1,2})[\/-](\d{1,2})[\/-](20\d{2})\b/;
const m2 = t.match(rxNum);
if (m2) {
const d = parseInt(m2[1], 10);
const m = parseInt(m2[2], 10);
const y = parseInt(m2[3], 10);
return { y, m, d };
}
return null;
}
function toISO({ y, m, d }: { y:number, m:number, d:number }) {
return `${y}-${pad2(m)}-${pad2(d)}`;
}
// Récupère le matricule d'un résultat de type "salarie"
function getSalarieMatricule(result: any): string | null {
try {
const meta = (result && typeof result === 'object') ? (result.meta || {}) : {};
const candidates = [
meta?.matricule,
meta?.code_salarie,
meta?.codeSalarie,
meta?.code,
meta?.['Code salarié'],
meta?.['code salarié'],
];
for (const c of candidates) {
if (typeof c === 'string' && c.trim()) return c.trim();
}
const url = String(result?.url || '');
if (url) {
// Essaye d'extraire depuis le chemin /salaries/<matricule>
const m = url.match(/\/(?:app\/)?salaries\/([^/?#]+)/i) || url.match(/\bsalaries\/([^/?#]+)/i);
if (m && m[1]) return decodeURIComponent(m[1]).trim();
}
} catch {}
return null;
}
// Détecte des intentions simples en FR: créer contrat (CDDU par défaut) / créer salarié
function detectActions(input: string) {
const q = normalize(input.trim());
if (!q) return [] as { id: string; title: string; subtitle?: string; url: string }[];
const wantsCreate = /(creer|créer|nouveau|ajouter|demarrer|démarrer|ouvrir)/.test(q);
const isContract = /(contrat|cddu|intermittent|spectacle|regime general|rgi|rg|classique|mono|multi)/.test(q);
const isEmployee = /(salarie|salarié|employe|employé|artiste|technicien|personne)/.test(q);
const actions: { id: string; title: string; subtitle?: string; url: string }[] = [];
if (wantsCreate && isContract) {
let type: "cddu" | "rg" = "cddu"; // par défaut CDDU
if (/\b(rg|regime general|rgi|classique)\b/.test(q)) type = "rg";
if (/\b(cddu|intermittent|spectacle)\b/.test(q)) type = "cddu";
actions.push({
id: `new-contrat-${type}`,
title: `Créer un contrat ${type.toUpperCase()}`,
subtitle: type === "cddu" ? "Par défaut si non précisé" : "Régime général",
url: `/contrats/nouveau?type=${type}`,
});
}
if (wantsCreate && isEmployee) {
actions.push({
id: `new-salarie`,
title: `Créer un salarié`,
subtitle: "Fiche salarié",
url: `/salaries/nouveau`,
});
}
// Accès / Utilisateurs : ouvrir la page de gestion ou démarrer une invitation
const isAccess = /(acces|accès|utilisateur|users?|membre|compte|invitation|inviter|droits|permission)/.test(q);
if (isAccess) {
// Si l'intention est de créer/ajouter/inviter -> lien direct vers le formulaire
if (/(creer|créer|ajouter|nouvel|nouveau|inviter|invite|ajoute|add)/.test(q)) {
actions.push({
id: `access-new`,
title: `Ajouter un accès`,
subtitle: `Inviter un utilisateur`,
url: `/vos-acces/nouveau`,
});
}
// Proposer l'ouverture de la gestion des accès de toute façon
actions.push({
id: `access-manage`,
title: `Gérer les accès`,
subtitle: `Voir et gérer les utilisateurs`,
url: `/vos-acces`,
});
}
// Intention: voir les cotisations de <mois|semestre|trimestre> <année> (+ variantes) ou plage de dates
const cotisationsIntent = /(\bcotis\b|\bcotisation[s]?\b|t[é|e]l[é|e]paiement[s]?)/.test(q) || /^cotis(ation)?s?:?\s*/.test(q) || /\bvoir\b.*\bcotis(ation)?/.test(q);
if (cotisationsIntent) {
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth() + 1;
const prevMonth = currentMonth === 1 ? 12 : currentMonth - 1;
const prevMonthYear = currentMonth === 1 ? currentYear - 1 : currentYear;
// 1) Plages de dates: "du 1 mars 2024 au 30 avril 2024" ou "01/03/2024 au 30/04/2024"
const range = q.match(/\b(du\s+)?([^\s].*?)\s+(?:au|->|jusqu'?au|to)\s+([^\s].*?)\b/);
if (range) {
const left = parseFRDateParts(range[2], currentYear);
const right = parseFRDateParts(range[3], left?.y ?? currentYear);
if (left && right) {
const fromISO = toISO(left);
const toISODate = toISO(right);
actions.push({
id: `see-cotis-range-${fromISO}-${toISODate}`,
title: `Voir les cotisations du ${pad2(left.d)}/${pad2(left.m)}/${left.y} au ${pad2(right.d)}/${pad2(right.m)}/${right.y}`,
subtitle: "Plage de dates",
url: `/cotisations?from=${fromISO}&to=${toISODate}&year=${left.y}&period=toute_annee`,
});
return actions;
}
}
// 2) Année spécifique
const yearMatch = q.match(/\b(20\d{2})\b/);
const year = yearMatch ? parseInt(yearMatch[1], 10) : undefined;
// 3) Mois spécifique (avec inférence d'année si absente)
let month: number | undefined;
let inferredYear: number | undefined;
for (const name in MONTH_NAME_MAP) {
if (q.includes(name)) { month = MONTH_NAME_MAP[name]; break; }
}
if (/\bce\s+mois\b|\bmois\s+en\s+cours\b/.test(q)) {
month = currentMonth;
inferredYear = currentYear;
}
if (/\b(mois\s+dernier|dernier\s+mois|mois\s+pr[ée]c[ée]dent)\b/.test(q)) {
month = prevMonth;
inferredYear = prevMonthYear;
}
// 4) Semestre (accepte 1er, 1re, 1e, S1, second, 2e, 2nd, S2)
let semester: 1|2|undefined;
if (/(premier|1er|1re|1e)\s*semestre|semestre\s*(premier|1er|1re|1e)|\bS1\b/.test(q)) semester = 1;
if (/(second|2e|2eme|2ème|2nd)\s*semestre|semestre\s*(second|2e|2eme|2ème|2nd)|\bS2\b/.test(q)) semester = 2;
// 5) Trimestre (accepte: "trimestre 1/1er/…", "1er trimestre", "tri 3", "T1..T4")
let trimestre: 1|2|3|4|undefined;
const tri = q.match(/\b(?:(?:trimestre|tri|t)\s*(?:n[oº°]\s*)?(1|2|3|4)\s*(?:er|re|e|eme|ème|nd)?|(1|2|3|4)\s*(?:er|re|e|eme|ème|nd)?\s*(?:trimestre|tri))\b/);
if (tri) {
const num = parseInt((tri[1] || tri[2]), 10);
if (num >= 1 && num <= 4) trimestre = num as 1|2|3|4;
}
// Construire l'action la plus précise trouvée
if (month) {
const y = year ?? inferredYear ?? currentYear;
actions.push({
id: `see-cotis-${y}-m${month}`,
title: `Voir les cotisations de ${month}/${y}`,
subtitle: "Période mensuelle",
url: `/cotisations?year=${y}&period=mois_${month}`,
});
} else if (year && semester) {
actions.push({
id: `see-cotis-${year}-s${semester}`,
title: `Voir les cotisations S${semester} ${year}`,
subtitle: "Période semestrielle",
url: `/cotisations?year=${year}&period=${semester === 1 ? "premier_semestre" : "second_semestre"}`,
});
} else if (trimestre) {
const y = year ?? currentYear;
actions.push({
id: `see-cotis-${y}-t${trimestre}`,
title: `Voir les cotisations T${trimestre} ${y}`,
subtitle: "Période trimestrielle",
url: `/cotisations?year=${y}&period=trimestre_${trimestre}`,
});
} else if (year) {
actions.push({
id: `see-cotis-${year}`,
title: `Voir les cotisations ${year}`,
subtitle: "Toute l'année",
url: `/cotisations?year=${year}&period=toute_annee`,
});
} else {
// Requête générique (ex: "cotis") -> raccourcis année courante
actions.push({
id: `see-cotis-${currentYear}`,
title: `Voir les cotisations ${currentYear}`,
subtitle: "Toute l'année",
url: `/cotisations?year=${currentYear}&period=toute_annee`,
});
actions.push({
id: `see-cotis-${currentYear}-s1`,
title: `Voir les cotisations S1 ${currentYear}`,
subtitle: "Période semestrielle",
url: `/cotisations?year=${currentYear}&period=premier_semestre`,
});
actions.push({
id: `see-cotis-${currentYear}-s2`,
title: `Voir les cotisations S2 ${currentYear}`,
subtitle: "Période semestrielle",
url: `/cotisations?year=${currentYear}&period=second_semestre`,
});
}
}
return actions;
}
export default function GlobalSearchOverlay() {
const isMac = typeof navigator !== "undefined" && navigator.platform?.toUpperCase().includes("MAC");
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const debouncedQuery = useDebouncedValue(query, 300);
const actions = detectActions(query);
const [activeType, setActiveType] = useState<string | null>(null);
const [helpOpen, setHelpOpen] = useState(false);
const [currentValue, setCurrentValue] = useState<string>("");
const [copiedToast, setCopiedToast] = useState(false);
// Fetch des résultats via /api/search
const { data: results = [], isLoading } = useQuery({
queryKey: ["global-search", debouncedQuery],
queryFn: async () => {
const q = debouncedQuery?.trim();
if (!q || q.length < 2) return [];
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
credentials: "include",
headers: { Accept: "application/json" },
});
if (!res.ok) throw new Error("search_api_error");
return res.json();
},
enabled: open && (debouncedQuery?.length ?? 0) >= 2,
staleTime: 10_000,
});
useEffect(() => {
if (Array.isArray(results) && results.length > 0) {
const order = ["salarie","contrat","paie","document","cotisation"];
const firstType = order.find((t) => (results as any[]).some((r) => r.entity_type === t)) || null;
setActiveType(firstType);
const first = (results as any[]).find((r) => r.entity_type === firstType) || (results as any[])[0];
if (first) setCurrentValue(`${first.entity_type}:${first.entity_id}`);
} else {
setActiveType(null);
}
}, [results]);
// Raccourcis clavier globaux (Cmd/Ctrl + K) + fermeture par ESC
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
const isMac = typeof navigator !== "undefined" && navigator.platform.toUpperCase().includes("MAC");
const meta = isMac ? e.metaKey : e.ctrlKey;
if (meta && (e.key === "k" || e.key === "K")) {
e.preventDefault();
setOpen((o) => !o);
}
// Fermer avec ESC
if (e.key === "Escape") {
setOpen(false);
setHelpOpen(false);
}
// Ouvrir/fermer l'aide avec '?' ou Ctrl/⌘ + '/'
if (open && (e.key === "?" || (e.key === "/" && (isMac ? e.metaKey : e.ctrlKey)))) {
e.preventDefault();
setHelpOpen((h) => !h);
}
// Ouvrir la sélection dans un nouvel onglet avec Shift + Entrée
if (open && e.key === "Enter" && e.shiftKey) {
e.preventDefault();
try {
const [t, id] = (currentValue || "").split(":");
if (t === "action") {
const a = (actions || []).find((x) => x.id === id);
if (a?.url) window.open(a.url, "_blank", "noopener,noreferrer");
return;
}
const list = Array.isArray(results) ? (results as any[]) : [];
const found = list.find((r: any) => r.entity_type === t && String(r.entity_id) === id);
if (found?.url) window.open(found.url, "_blank", "noopener,noreferrer");
} catch {}
}
// Copier le lien de la sélection avec ⌘/Ctrl + C
if (open && (isMac ? e.metaKey : e.ctrlKey) && (e.key === "c" || e.key === "C")) {
const [t, id] = (currentValue || "").split(":");
if (t === "action") {
const a = (actions || []).find((x) => x.id === id);
if (a?.url && navigator?.clipboard?.writeText) {
e.preventDefault();
navigator.clipboard.writeText(a.url).then(() => {
setCopiedToast(true);
setTimeout(() => setCopiedToast(false), 1500);
}).catch(() => {});
}
} else {
const list = Array.isArray(results) ? (results as any[]) : [];
const found = list.find((r: any) => r.entity_type === t && String(r.entity_id) === id);
if (found?.url && navigator?.clipboard?.writeText) {
e.preventDefault();
navigator.clipboard.writeText(found.url).then(() => {
setCopiedToast(true);
setTimeout(() => setCopiedToast(false), 1500);
}).catch(() => {});
}
}
}
};
const onOpen = () => setOpen(true);
window.addEventListener("keydown", onKeyDown);
window.addEventListener("open-global-search" as any, onOpen as any);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("open-global-search" as any, onOpen as any);
};
}, [open, currentValue, results, actions]);
// Formatage FR des dates pour les métadonnées de contrats
const fmtDate = (s?: string) => {
if (!s) return "";
const d = new Date(s);
if (isNaN(d.getTime())) return s;
return d.toLocaleDateString("fr-FR");
};
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
className="cmdk-overlay"
onClick={() => setOpen(false)}
>
<div
className="cmdk-container"
onClick={(e) => e.stopPropagation()}
>
<div className="">
<div className="cmdk-header">
<h3 className="cmdk-title">Palette de Commandes · Raccourci&nbsp;{isMac ? "⌘K" : "Ctrl+K"}</h3>
<p className="cmdk-subtitle">Utilisez cet outil pour naviguer dans l'Espace Paie Odentas et effectuer des actions.</p>
</div>
<Command
label="Rechercher dans lEspace Paie"
// Désactive le filtrage interne de cmdk (on filtre côté API/UI)
filter={() => 1}
className="cmdk-root"
>
<div className="cmdk-input-wrapper">
<Command.Input
autoFocus
value={query}
onValueChange={setQuery}
placeholder="Rechercher un contrat, un salarié, une cotisation… ou effectuer une action en langage naturel"
className="cmdk-input"
/>
<kbd className="cmdk-esc">ESC</kbd>
</div>
<Command.List className="cmdk-list">
{actions.length > 0 && (
<Command.Group heading="ACTIONS">
{actions.map((a) => {
const val = `action:${a.id}`;
return (
<Command.Item
key={`action-${a.id}`}
className="cmdk-item"
onSelect={() => {
window.location.href = a.url;
setOpen(false);
}}
onMouseEnter={() => { setActiveType("action"); setCurrentValue(val); }}
onFocus={() => { setActiveType("action"); setCurrentValue(val); }}
value={val}
>
<div className="cmdk-item-main">
<div className="cmdk-item-left">
<span className="cmdk-item-icon" aria-hidden="true">⚡</span>
<div className="min-w-0">
<div className="text-sm truncate">{a.title}</div>
{a.subtitle && (
<div className="cmdk-item-subtitle truncate">{a.subtitle}</div>
)}
</div>
</div>
</div>
</Command.Item>
);
})}
</Command.Group>
)}
{helpOpen && (
<div className="cmdk-help-panel" role="note">
<div className="cmdk-help-section">
<div className="cmdk-help-title">Raccourcis clavier</div>
<ul className="cmdk-help-grid">
<li><span className="cmdk-kbd">⌘/Ctrl</span> + <span className="cmdk-kbd">K</span> — Ouvrir/Fermer</li>
<li><span className="cmdk-kbd">ESC</span> — Fermer</li>
<li><span className="cmdk-kbd">↑</span> / <span className="cmdk-kbd">↓</span> — Naviguer</li>
<li><span className="cmdk-kbd">Entrée</span> — Ouvrir l'élément</li>
<li><span className="cmdk-kbd">/Ctrl</span> + <span className="cmdk-kbd">C</span> Copier le lien</li>
<li><span className="cmdk-kbd">?</span> Afficher/Masquer l'aide</li>
</ul>
</div>
</div>
)}
{(!debouncedQuery || debouncedQuery.length < 2) && actions.length === 0 && (
<Command.Empty className="cmdk-empty">
Tapez au moins 2 caractères pour commencer.
</Command.Empty>
)}
{debouncedQuery && debouncedQuery.length >= 2 && isLoading && (
<Command.Empty className="cmdk-empty">
Recherche en cours…
</Command.Empty>
)}
{debouncedQuery && debouncedQuery.length >= 2 && !isLoading && (results as any[]).length === 0 && (
<Command.Empty className="cmdk-empty">
Aucun résultat.
</Command.Empty>
)}
{debouncedQuery && debouncedQuery.length >= 2 && !isLoading && (results as any[]).length > 0 && (
<>
{["salarie","contrat","paie","document","cotisation"].map((type) => {
const items = (results as any[]).filter((r) => r.entity_type === type);
// Si contrats: trier par date de début (meta.debut) décroissante et limiter à 5
if (type === "contrat") {
const parseDate = (s: any) => {
if (!s) return 0;
const d = new Date(s);
return isNaN(d.getTime()) ? 0 : d.getTime();
};
items.sort((a: any, b: any) => parseDate(b?.meta?.debut) - parseDate(a?.meta?.debut));
// Limite à 5 résultats contrats
while (items.length > 5) items.pop();
}
if (!items.length) return null;
return (
<Command.Group key={type} heading={type.toUpperCase()}>
{items.map((r: any) => {
const val = `${r.entity_type}:${r.entity_id}`;
return (
<Fragment key={`${r.entity_type}-${r.entity_id}-wrap`}>
<Command.Item
key={`${r.entity_type}-${r.entity_id}`}
className="cmdk-item"
onSelect={() => {
window.location.href = r.url;
setOpen(false);
}}
onMouseEnter={() => { setActiveType(r.entity_type); setCurrentValue(val); }}
onFocus={() => { setActiveType(r.entity_type); setCurrentValue(val); }}
value={val}
>
<div className="cmdk-item-main">
<div className="cmdk-item-left">
<span className="cmdk-item-icon" aria-hidden="true">🔎</span>
<div className="min-w-0">
<div className="text-sm truncate">{r.title}</div>
{r.subtitle && (
<div className="cmdk-item-subtitle truncate">{r.subtitle}</div>
)}
{r.entity_type === "contrat" && (
<div className="cmdk-item-meta truncate">
{r?.meta?.spectacle && <span className="truncate">{r.meta.spectacle}</span>}
{(r?.meta?.debut || r?.meta?.fin) && (
<>
{r?.meta?.spectacle && <span className="cmdk-dot">•</span>}
<span>{fmtDate(r?.meta?.debut)}{r?.meta?.fin ? " → " + fmtDate(r.meta.fin) : ""}</span>
</>
)}
</div>
)}
</div>
</div>
{r.entity_type === "contrat" && (
<div className="cmdk-item-right">
{(() => {
const isSigned = (r?.meta?.is_signed === true) || (String(r?.meta?.contrat_signe || '').toLowerCase() === 'oui');
return (
<span className={`cmdk-badge ${isSigned ? "cmdk-badge--success" : "cmdk-badge--muted"}`}>
{isSigned ? "Signé" : "Non signé"}
</span>
);
})()}
</div>
)}
</div>
</Command.Item>
{r.entity_type === 'salarie' && (
<Command.Item
key={`${r.entity_type}-${r.entity_id}-create-contrat`}
className="cmdk-item"
onSelect={() => {
try {
const matricule = getSalarieMatricule(r);
if (matricule && matricule.trim()) {
window.location.href = `/contrats/nouveau?salarie=${encodeURIComponent(matricule.trim())}`;
setOpen(false);
}
} catch {}
}}
onMouseEnter={() => { setActiveType('action'); setCurrentValue(`action:create-contrat:${r.entity_id}`); }}
onFocus={() => { setActiveType('action'); setCurrentValue(`action:create-contrat:${r.entity_id}`); }}
value={`action:create-contrat:${r.entity_id}`}
>
<div className="cmdk-item-main">
<div className="cmdk-item-left">
<span className="cmdk-item-icon" aria-hidden="true">⚡</span>
<div className="min-w-0">
<div className="text-sm truncate">Créer un contrat CDDU pour {r.title}</div>
<div className="cmdk-item-subtitle truncate">Formulaire nouveau contrat</div>
</div>
</div>
</div>
</Command.Item>
)}
</Fragment>
);
})}
</Command.Group>
);
})}
</>
)}
</Command.List>
<div className="cmdk-footer">
<div className="cmdk-hint">
<div className="cmdk-hint-left">
<span className="cmdk-kbd">↵</span>
<span>
{activeType === "action" && "Exécuter l'action"}
{activeType === "salarie" && "Ouvrir le salarié"}
{activeType === "contrat" && "Ouvrir le contrat"}
{activeType === "paie" && "Ouvrir la paie"}
{activeType === "document" && "Ouvrir le document"}
{activeType === "cotisation" && "Ouvrir la cotisation"}
{!activeType && " ouvrir"}
</span>
</div>
<div className="cmdk-hint-right">
<span className="cmdk-dot">•</span>
<span className="cmdk-kbd">↑</span>
<span className="cmdk-kbd">↓</span>
<span> pour naviguer </span>
<span className="cmdk-dot">•</span>
<span className="cmdk-kbd">⇧</span><span className="cmdk-kbd">↵</span>
<span> nouvel onglet </span>
<span className="cmdk-dot">•</span>
<button type="button" className="cmdk-helpbtn" onClick={() => setHelpOpen((h) => !h)} aria-pressed={helpOpen} aria-label="Afficher l'aide">?
</button>
<span> aide</span>
</div>
</div>
</div>
</Command>
{copiedToast && (
<div className="cmdk-toast" role="status" aria-live="polite">Lien copié </div>
)}
<style jsx>{`
.cmdk-header { padding: 10px 12px 0; }
.cmdk-title { margin: 0 0 2px 0; font-size: 18px; font-weight: 600; }
.cmdk-subtitle { margin: 0; font-size: 12px; color: var(--muted-foreground, #666); }
.cmdk-helpbar { margin: 6px 12px 0; font-size: 12px; display: flex; align-items: center; gap: 6px; color: var(--muted-foreground, #666); }
.cmdk-help-panel { padding: 12px; border-top: 1px solid rgba(0,0,0,0.06); background: var(--panel, #fafafa); }
.cmdk-help-section { margin-bottom: 10px; }
.cmdk-help-title { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; opacity: .8; margin-bottom: 6px; }
.cmdk-help-grid { margin: 0; padding-left: 16px; display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 6px 16px; font-size: 13px; }
.cmdk-help-tip { font-size: 12px; color: var(--muted-foreground, #666); margin-top: 6px; }
.cmdk-kbd { display: inline-block; border: 1px solid rgba(0,0,0,0.2); border-bottom-width: 2px; padding: 0 6px; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; line-height: 18px; }
.cmdk-dot { margin: 0 6px; opacity: .6; }
.cmdk-hint { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; border-top: 1px solid rgba(0,0,0,0.06); font-size: 12px; color: var(--muted-foreground, #666); }
.cmdk-hint-right { display: flex; align-items: center; gap: 6px; }
.cmdk-helpbtn { border: 1px solid rgba(0,0,0,0.2); border-bottom-width: 2px; border-radius: 4px; font: inherit; padding: 0 8px; height: 22px; background: transparent; cursor: pointer; }
.cmdk-tagline { display: block; padding: 6px 12px 10px; font-size: 11px; color: var(--muted-foreground, #666); opacity: .75; text-align: center; }
.cmdk-toast { position: absolute; right: 12px; bottom: 12px; padding: 6px 10px; font-size: 12px; border: 1px solid rgba(0,0,0,0.08); border-radius: 6px; background: rgba(0,0,0,0.75); color: #fff; box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
`}</style>
</div>
</div>
</div>
);
}