Files
leocrm/frontend/src/components/layout/PluginToolbar.tsx
T

240 lines
8.9 KiB
TypeScript

import React from 'react';
import { usePluginToolbarStore, type ToolbarItem } from '@/store/pluginToolbarStore';
function ToolbarButton({ item }: { item: ToolbarItem }) {
return (
<button
onClick={item.onClick}
disabled={item.disabled}
className={`
inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
transition-colors duration-100
${item.active ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900'}
${item.disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}
`}
title={item.label}
>
{item.icon && <span className="w-4 h-4 flex-shrink-0">{item.icon}</span>}
<span className="hidden sm:inline">{item.label}</span>
</button>
);
}
function ToolbarSearch({ item }: { item: ToolbarItem }) {
const [expanded, setExpanded] = React.useState(false);
const [value, setValue] = React.useState('');
const inputRef = React.useRef<HTMLInputElement>(null);
const handleToggle = () => {
if (expanded) {
// Collapse: clear and close
setValue('');
item.onSearch?.('');
setExpanded(false);
} else {
setExpanded(true);
setTimeout(() => inputRef.current?.focus(), 50);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
item.onSearch?.(e.target.value);
};
if (!expanded) {
return (
<button
onClick={handleToggle}
title={item.label || 'Suchen'}
aria-label={item.label || 'Suchen'}
className="inline-flex items-center justify-center p-1.5 rounded text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900 transition-colors cursor-pointer flex-shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</button>
);
}
return (
<div className="flex items-center flex-shrink-0">
<input
ref={inputRef}
type="text"
value={value}
placeholder={item.searchPlaceholder || 'Suchen...'}
onChange={handleChange}
onBlur={() => { if (!value) { setExpanded(false); } }}
className="w-28 sm:w-40 px-2 py-1 text-xs border border-secondary-200 rounded
focus:outline-none focus:border-primary-400 focus:ring-1 focus:ring-primary-300
placeholder:text-secondary-400"
/>
<button
onClick={handleToggle}
className="ml-1 p-1 rounded hover:bg-secondary-100 text-secondary-400 hover:text-secondary-600 transition-colors"
aria-label="Suche schließen"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
);
}
function ToolbarDropdown({ item }: { item: ToolbarItem }) {
const [open, setOpen] = React.useState(false);
const ref = React.useRef<HTMLDivElement>(null);
const menuRef = React.useRef<HTMLDivElement>(null);
const [menuPos, setMenuPos] = React.useState({ top: 0, left: 0 });
React.useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
const target = e.target as Node;
if (ref.current?.contains(target) || menuRef.current?.contains(target)) return;
setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const handleToggle = () => {
if (!open && ref.current) {
const rect = ref.current.getBoundingClientRect();
setMenuPos({ top: rect.bottom + 4, left: rect.left });
}
setOpen(!open);
};
const activeOption = item.menuOptions?.find((o) => o.active);
let lastSection: string | undefined = undefined;
return (
<>
<div ref={ref} className="relative flex-shrink-0">
<button
onClick={handleToggle}
title={item.label}
aria-label={item.label}
className={`
inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
transition-colors duration-100 cursor-pointer
${open || activeOption ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900'}
`}
>
{item.icon && <span className="w-4 h-4 flex-shrink-0">{item.icon}</span>}
{!item.iconOnly && <span className="hidden sm:inline">{activeOption?.label || item.label}</span>}
<svg className={`w-3 h-3 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</div>
{open && (
<div
ref={menuRef}
className="fixed bg-white border border-secondary-200 rounded-lg shadow-lg py-1"
style={{
top: menuPos.top,
left: menuPos.left,
minWidth: item.menuWidth || '180px',
zIndex: 3000,
}}
>
{item.menuOptions?.map((opt) => {
const showSection = opt.section && opt.section !== lastSection;
if (showSection) lastSection = opt.section;
return (
<React.Fragment key={opt.value}>
{showSection && (
<div className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-secondary-400">
{opt.section}
</div>
)}
{opt.separator && <div className="my-1 border-t border-secondary-100" />}
<button
onClick={() => {
opt.onClick();
setOpen(false);
}}
disabled={opt.disabled}
className={`
w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors
${opt.active ? 'bg-primary-50 text-primary-700 font-medium' : 'text-secondary-700 hover:bg-secondary-100'}
${opt.disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}
`}
>
{opt.icon && <span className="w-4 h-4 flex-shrink-0">{opt.icon}</span>}
<span className="flex-1">{opt.label}</span>
{opt.active && (
<svg className="w-3.5 h-3.5 text-primary-600" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</button>
</React.Fragment>
);
})}
</div>
)}
</>
);
}
function ToolbarSelect({ item }: { item: ToolbarItem }) {
return (
<select
value={item.selectValue || ''}
onChange={(e) => item.onSelect?.(e.target.value)}
className="px-1.5 sm:px-2 py-1 text-xs border border-secondary-200 rounded
focus:outline-none focus:border-primary-400 focus:ring-1 focus:ring-primary-300
bg-white cursor-pointer flex-shrink-0 max-w-[120px] sm:max-w-none"
>
{item.selectOptions?.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
);
}
export function PluginToolbar() {
const { items, activePlugin } = usePluginToolbarStore();
const visibleItems = items.filter((i) => i.plugin === activePlugin);
if (visibleItems.length === 0) return null;
// Group items by group field
const groups: Record<string, ToolbarItem[]> = {};
for (const item of visibleItems) {
const g = item.group || 'default';
if (!groups[g]) groups[g] = [];
groups[g].push(item);
}
const groupNames = Object.keys(groups);
return (
<div
className="flex items-center gap-1 px-2 sm:px-3 py-1.5 bg-white border-b border-secondary-200 min-h-[43px] overflow-visible relative z-30"
data-testid="plugin-toolbar"
>
{groupNames.map((groupName, gi) => (
<React.Fragment key={groupName}>
{gi > 0 && <div className="w-px h-5 bg-secondary-200 mx-1" />}
<div className="flex items-center gap-1">
{groups[groupName].map((item) => {
if (item.type === 'search') return <ToolbarSearch key={item.id} item={item} />;
if (item.type === 'select') return <ToolbarSelect key={item.id} item={item} />;
if (item.type === 'dropdown') return <ToolbarDropdown key={item.id} item={item} />;
if (item.type === 'custom' && item.customComponent) return <React.Fragment key={item.id}>{item.customComponent}</React.Fragment>;
return <ToolbarButton key={item.id} item={item} />;
})}
</div>
</React.Fragment>
))}
</div>
);
}