feat: GroupPanel + toolbar reorder (New, View, Search, Filter, Sort, Group)

This commit is contained in:
Agent Zero
2026-07-28 10:34:30 +02:00
parent 11ffffcb44
commit 05cc51609b
2 changed files with 459 additions and 23 deletions
@@ -0,0 +1,415 @@
/**
* SmartSuite-style Group Panel for contacts.
* Multi-field grouping with priority ordering.
*/
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Group as GroupIcon, Plus, X } from 'lucide-react';
import type { UnifiedContact } from '@/api/unifiedContacts';
// ─── Field definitions ────────────────────────────────────────────────────────
type FieldType = 'text' | 'number' | 'select' | 'date';
interface GroupFieldDef {
key: string;
label: string;
type: FieldType;
group?: 'general' | 'company' | 'person' | 'address' | 'notes' | 'meta';
}
const GROUP_FIELDS: GroupFieldDef[] = [
// General
{ key: 'type', label: 'Typ', type: 'select', group: 'general' },
{ key: 'displayname', label: 'Anzeigename', type: 'text', group: 'general' },
{ key: 'email_1', label: 'E-Mail 1', type: 'text', group: 'general' },
{ key: 'phone_1', label: 'Telefon 1', type: 'text', group: 'general' },
{ key: 'website', label: 'Website', type: 'text', group: 'general' },
{ key: 'tags', label: 'Tags', type: 'text', group: 'general' },
// Company
{ key: 'name', label: 'Firmenname', type: 'text', group: 'company' },
{ key: 'code', label: 'Kunden-Nr.', type: 'text', group: 'company' },
{ key: 'accounting_code', label: 'Buchhaltungs-Code', type: 'text', group: 'company' },
{ key: 'vat_code', label: 'USt-IdNr.', type: 'text', group: 'company' },
{ key: 'fiscal_code', label: 'Steuernummer', type: 'text', group: 'company' },
{ key: 'commerce_code', label: 'Handelsregister-Nr.', type: 'text', group: 'company' },
{ key: 'bic', label: 'BIC', type: 'text', group: 'company' },
{ key: 'bank_account', label: 'Bankkonto', type: 'text', group: 'company' },
// Person
{ key: 'firstname', label: 'Vorname', type: 'text', group: 'person' },
{ key: 'surname', label: 'Nachname', type: 'text', group: 'person' },
{ key: 'suffix', label: 'Suffix', type: 'text', group: 'person' },
{ key: 'gender', label: 'Geschlecht', type: 'select', group: 'person' },
{ key: 'ext_name_line', label: 'Zusatzname', type: 'text', group: 'person' },
// Address
{ key: 'mailing_street', label: 'Straße (Post)', type: 'text', group: 'address' },
{ key: 'mailing_postalcode', label: 'PLZ (Post)', type: 'text', group: 'address' },
{ key: 'mailing_city', label: 'Stadt (Post)', type: 'text', group: 'address' },
{ key: 'mailing_state', label: 'Bundesland (Post)', type: 'text', group: 'address' },
{ key: 'mailing_country', label: 'Land (Post)', type: 'text', group: 'address' },
{ key: 'visit_city', label: 'Stadt (Besuch)', type: 'text', group: 'address' },
{ key: 'visit_postalcode', label: 'PLZ (Besuch)', type: 'text', group: 'address' },
{ key: 'invoice_city', label: 'Stadt (Rechnung)', type: 'text', group: 'address' },
{ key: 'invoice_postalcode', label: 'PLZ (Rechnung)', type: 'text', group: 'address' },
// Notes
{ key: 'projectnote', label: 'Projektnotiz', type: 'text', group: 'notes' },
{ key: 'projectnote_title', label: 'Notiztitel', type: 'text', group: 'notes' },
{ key: 'contact_warning', label: 'Warnung', type: 'text', group: 'notes' },
// Meta
{ key: 'folder_id', label: 'Ordner-ID', type: 'text', group: 'meta' },
{ key: 'created_at', label: 'Erstellt am', type: 'date', group: 'meta' },
{ key: 'updated_at', label: 'Geändert am', type: 'date', group: 'meta' },
];
// ─── Types ────────────────────────────────────────────────────────────────────
export interface GroupCondition {
id: string;
field: string;
}
export interface GroupState {
conditions: GroupCondition[];
}
export const emptyGroupState: GroupState = {
conditions: [],
};
// ─── Group logic ──────────────────────────────────────────────────────────────
function getFieldValue(contact: UnifiedContact, field: string): any {
return (contact as any)[field];
}
export interface GroupedContacts {
key: string;
label: string;
contacts: UnifiedContact[];
}
export function applyGrouping(contacts: UnifiedContact[], groupState: GroupState): GroupedContacts[] {
if (!groupState.conditions.length) {
return [{ key: 'all', label: 'Alle', contacts }];
}
// Multi-level grouping
let groups: GroupedContacts[] = [{ key: 'all', label: 'Alle', contacts: [...contacts] }];
for (const cond of groupState.conditions) {
const def = GROUP_FIELDS.find((f) => f.key === cond.field);
if (!def) continue;
const newGroups: GroupedContacts[] = [];
for (const group of groups) {
const subGroups = new Map<string, UnifiedContact[]>();
for (const contact of group.contacts) {
const val = getFieldValue(contact, cond.field);
const groupKey = val == null || val === '' ? '(leer)' : String(val);
if (!subGroups.has(groupKey)) {
subGroups.set(groupKey, []);
}
subGroups.get(groupKey)!.push(contact);
}
// Sort group keys alphabetically
const sortedKeys = Array.from(subGroups.keys()).sort((a, b) => {
if (a === '(leer)') return 1;
if (b === '(leer)') return -1;
return a.localeCompare(b);
});
for (const key of sortedKeys) {
const label = group.key === 'all'
? `${def.label}: ${key}`
: `${group.label} > ${key}`;
newGroups.push({ key: `${group.key}::${key}`, label, contacts: subGroups.get(key)! });
}
}
groups = newGroups;
}
return groups;
}
// ─── Context-sensitive field ordering ──────────────────────────────────────────
function getOrderedFields(contactType?: 'company' | 'person' | undefined): GroupFieldDef[] {
if (contactType === 'company') {
const order = ['general', 'company', 'address', 'notes', 'meta', 'person'];
return [...GROUP_FIELDS].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
}
if (contactType === 'person') {
const order = ['general', 'person', 'address', 'notes', 'meta', 'company'];
return [...GROUP_FIELDS].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
}
const order = ['general', 'company', 'person', 'address', 'notes', 'meta'];
return [...GROUP_FIELDS].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
}
// ─── Component ────────────────────────────────────────────────────────────────
interface GroupPanelProps {
groupState: GroupState;
onGroupChange: (groupState: GroupState) => void;
contactType?: 'company' | 'person' | undefined;
}
let groupIdCounter = 0;
function newGroupId() {
return `grp-${Date.now()}-${++groupIdCounter}`;
}
export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPanelProps) {
const [open, setOpen] = useState(false);
const btnRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [panelPos, setPanelPos] = useState({ top: 0, left: 0 });
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
const target = e.target as Node;
if (btnRef.current?.contains(target) || panelRef.current?.contains(target)) return;
setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const handleToggle = () => {
if (!open && btnRef.current) {
const rect = btnRef.current.getBoundingClientRect();
setPanelPos({ top: rect.bottom + 4, left: rect.left });
}
setOpen(!open);
};
const orderedFields = useMemo(() => getOrderedFields(contactType), [contactType]);
const activeCount = groupState.conditions.length;
const addCondition = () => {
onGroupChange({
conditions: [...groupState.conditions, {
id: newGroupId(),
field: orderedFields[0]?.key || 'type',
}],
});
};
const removeCondition = (id: string) => {
onGroupChange({
conditions: groupState.conditions.filter((c) => c.id !== id),
});
};
const updateCondition = (id: string, updates: Partial<GroupCondition>) => {
onGroupChange({
conditions: groupState.conditions.map((c) =>
c.id === id ? { ...c, ...updates } : c
),
});
};
const clearAll = () => {
onGroupChange({ ...emptyGroupState });
};
const moveCondition = (id: string, direction: 'up' | 'down') => {
const conds = [...groupState.conditions];
const idx = conds.findIndex((c) => c.id === id);
if (idx < 0) return;
const newIdx = direction === 'up' ? idx - 1 : idx + 1;
if (newIdx < 0 || newIdx >= conds.length) return;
[conds[idx], conds[newIdx]] = [conds[newIdx], conds[idx]];
onGroupChange({ conditions: conds });
};
// Group fields for dropdown
const groupedFields = useMemo(() => {
const groups: Record<string, GroupFieldDef[]> = {};
for (const f of orderedFields) {
const g = f.group || 'general';
if (!groups[g]) groups[g] = [];
groups[g].push(f);
}
return groups;
}, [orderedFields]);
const groupLabels: Record<string, string> = {
general: 'Allgemein',
company: 'Firma',
person: 'Person',
address: 'Adresse',
notes: 'Notizen',
meta: 'Metadaten',
};
return (
<>
<div ref={btnRef} className="relative flex-shrink-0">
<button
onClick={handleToggle}
title="Gruppierung"
aria-label="Gruppierung"
className={`
inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
transition-colors duration-100 cursor-pointer relative
${open || activeCount > 0 ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900'}
`}
>
<GroupIcon className="w-3.5 h-3.5" strokeWidth={2} />
{activeCount > 0 && (
<span className="inline-flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-bold rounded-full bg-primary-600 text-white">
{activeCount}
</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={panelRef}
className="fixed bg-white border border-secondary-200 rounded-lg shadow-lg"
style={{
top: panelPos.top,
left: panelPos.left,
width: '420px',
maxHeight: '70vh',
overflowY: 'auto',
zIndex: 3000,
}}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary-100">
<span className="text-sm font-semibold text-secondary-800">Gruppierung</span>
<div className="flex items-center gap-2">
{activeCount > 0 && (
<button
onClick={clearAll}
className="text-xs text-secondary-500 hover:text-red-600 transition-colors"
>
Alle löschen
</button>
)}
<button
onClick={() => setOpen(false)}
className="p-1 rounded hover:bg-secondary-100 text-secondary-400 hover:text-secondary-600"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Info text */}
{activeCount === 0 && (
<div className="px-4 py-3 border-b border-secondary-100">
<div className="text-center py-4 text-xs text-secondary-400">
Keine Gruppierung aktiv. Alle Datensätze werden in einer flachen Liste angezeigt.
</div>
</div>
)}
{/* Active group badges */}
{activeCount > 0 && (
<div className="flex flex-wrap gap-1.5 px-4 py-2 border-b border-secondary-100">
{groupState.conditions.map((cond, idx) => {
const def = GROUP_FIELDS.find((f) => f.key === cond.field);
return (
<span
key={cond.id}
className="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] rounded-full bg-primary-50 text-primary-700 border border-primary-200"
>
<span className="font-bold text-primary-500">{idx + 1}.</span>
{def?.label}
<button onClick={() => removeCondition(cond.id)} className="hover:text-red-600">
<X className="w-3 h-3" />
</button>
</span>
);
})}
</div>
)}
{/* Group rows */}
<div className="px-4 py-3 space-y-2">
{groupState.conditions.map((cond, idx) => (
<div key={cond.id} className="flex items-center gap-1.5">
{/* Priority number + reorder */}
<div className="flex flex-col items-center w-6 flex-shrink-0">
<span className="text-[10px] font-bold text-primary-600">{idx + 1}</span>
<div className="flex flex-col">
<button
onClick={() => moveCondition(cond.id, 'up')}
disabled={idx === 0}
className="text-secondary-300 hover:text-secondary-600 disabled:opacity-30 leading-none"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<polyline points="18 15 12 9 6 15" />
</svg>
</button>
<button
onClick={() => moveCondition(cond.id, 'down')}
disabled={idx === groupState.conditions.length - 1}
className="text-secondary-300 hover:text-secondary-600 disabled:opacity-30 leading-none"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</div>
</div>
{/* Field dropdown */}
<select
value={cond.field}
onChange={(e) => updateCondition(cond.id, { field: e.target.value })}
className="flex-1 min-w-0 px-2 py-1.5 text-xs border border-secondary-200 rounded bg-white cursor-pointer focus:outline-none focus:border-primary-400"
>
{Object.entries(groupedFields).map(([group, fields]) => (
<optgroup key={group} label={groupLabels[group] || group}>
{fields.map((f) => (
<option key={f.key} value={f.key}>{f.label}</option>
))}
</optgroup>
))}
</select>
{/* Remove button */}
<button
onClick={() => removeCondition(cond.id)}
className="p-1 rounded hover:bg-red-50 text-secondary-400 hover:text-red-600 transition-colors flex-shrink-0"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-3 border-t border-secondary-100">
<button
onClick={addCondition}
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Gruppierung hinzufügen
</button>
<button
onClick={() => setOpen(false)}
className="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded transition-colors"
>
Fertig
</button>
</div>
</div>
)}
</>
);
}
+44 -23
View File
@@ -22,6 +22,7 @@ import type { Tag } from '@/api/tags';
import { ArrowDownAZ, ArrowUpZA, Bookmark, ChevronLeft, ExternalLink, LayoutGrid, List, Plus, Printer, Table2 } from 'lucide-react';
import { FilterPanel, applyFilters, emptyFilterState, type FilterState } from '@/components/contacts/FilterPanel';
import { SortPanel, applySorting, emptySortState, type SortState } from '@/components/contacts/SortPanel';
import { GroupPanel, applyGrouping, emptyGroupState, type GroupState, type GroupedContacts } from '@/components/contacts/GroupPanel';
import {
useUnifiedContacts,
useUnifiedContact,
@@ -46,6 +47,7 @@ export function ContactsListPage() {
const [selectedTags, setSelectedTags] = useState<Tag[]>([]);
const [filterState, setFilterState] = useState<FilterState>(emptyFilterState);
const [sortState, setSortState] = useState<SortState>(emptySortState);
const [groupState, setGroupState] = useState<GroupState>(emptyGroupState);
const openWindow = useWindowStore((s) => s.openWindow);
// Debounce search
@@ -120,6 +122,9 @@ export function ContactsListPage() {
return result;
}, [contacts, tagFilter, filterState, sortState]);
// Apply GroupPanel grouping
const groupedContacts = useMemo(() => applyGrouping(filteredContacts, groupState), [filteredContacts, groupState]);
// Handle folder selection
const handleSelectFilter = useCallback((filter: ContactFilter) => {
setSelectedFilter(filter);
@@ -209,6 +214,32 @@ export function ContactsListPage() {
useEffect(() => {
const items = [
// New contact — ganz links
{
id: 'new',
plugin: 'contacts',
label: t('contacts.create'),
type: 'button' as const,
group: 'create',
icon: <Plus className="w-3.5 h-3.5" strokeWidth={2} />,
onClick: handleCreate,
},
// View mode dropdown (list / table / cards)
{
id: 'view-mode',
plugin: 'contacts',
label: t('common.view', 'Ansicht'),
type: 'dropdown' as const,
group: 'view',
icon: <LayoutGrid className="w-3.5 h-3.5" strokeWidth={2} />,
menuWidth: '180px',
menuOptions: [
{ value: 'list', label: t('contacts.viewList'), icon: <List className="w-3.5 h-3.5" strokeWidth={2} />, active: viewMode === 'list', onClick: () => setViewMode('list') },
{ value: 'table', label: t('contacts.viewTable'), icon: <Table2 className="w-3.5 h-3.5" strokeWidth={2} />, active: viewMode === 'table', onClick: () => setViewMode('table') },
{ value: 'cards', label: t('contacts.viewCards'), icon: <LayoutGrid className="w-3.5 h-3.5" strokeWidth={2} />, active: viewMode === 'cards', onClick: () => setViewMode('cards') },
],
onClick: () => {},
},
// Search
{
id: 'search',
@@ -252,20 +283,20 @@ export function ContactsListPage() {
),
onClick: () => {},
},
// View mode dropdown (list / table / cards)
// GroupPanel — SmartSuite-style multi-field grouping
{
id: 'view-mode',
id: 'group-panel',
plugin: 'contacts',
label: t('common.view', 'Ansicht'),
type: 'dropdown' as const,
group: 'view',
icon: <LayoutGrid className="w-3.5 h-3.5" strokeWidth={2} />,
menuWidth: '180px',
menuOptions: [
{ value: 'list', label: t('contacts.viewList'), icon: <List className="w-3.5 h-3.5" strokeWidth={2} />, active: viewMode === 'list', onClick: () => setViewMode('list') },
{ value: 'table', label: t('contacts.viewTable'), icon: <Table2 className="w-3.5 h-3.5" strokeWidth={2} />, active: viewMode === 'table', onClick: () => setViewMode('table') },
{ value: 'cards', label: t('contacts.viewCards'), icon: <LayoutGrid className="w-3.5 h-3.5" strokeWidth={2} />, active: viewMode === 'cards', onClick: () => setViewMode('cards') },
],
label: 'Gruppierung',
type: 'custom' as const,
group: 'group',
customComponent: (
<GroupPanel
groupState={groupState}
onGroupChange={setGroupState}
contactType={contactType}
/>
),
onClick: () => {},
},
// Saved filters
@@ -278,16 +309,6 @@ export function ContactsListPage() {
icon: <Bookmark className="w-3.5 h-3.5" strokeWidth={2} />,
onClick: () => setSavedFiltersOpen(true),
},
// New contact
{
id: 'new',
plugin: 'contacts',
label: t('contacts.create'),
type: 'button' as const,
group: 'actions',
icon: <Plus className="w-3.5 h-3.5" strokeWidth={2} />,
onClick: handleCreate,
},
// Print
{
id: 'print',
@@ -311,7 +332,7 @@ export function ContactsListPage() {
];
registerItems('contacts', items);
return () => unregisterPlugin('contacts');
}, [handleSearch, handleCreate, handleSelectFilter, t, registerItems, unregisterPlugin, selectedFilter, viewMode, sortOptions, viewModeOptions, filterState, contactType, sortState]);
}, [handleSearch, handleCreate, handleSelectFilter, t, registerItems, unregisterPlugin, selectedFilter, viewMode, sortOptions, viewModeOptions, filterState, contactType, sortState, groupState]);
return (
<div className="flex flex-col h-full" data-testid="contacts-list-page">