feat: column visibility, bulk actions, custom sort drag-drop, custom fields in filter/sort/group, mobile optimization

This commit is contained in:
Agent Zero
2026-07-28 23:14:15 +02:00
parent 9681827395
commit 784a771039
5 changed files with 601 additions and 64 deletions
@@ -6,9 +6,11 @@ import { Badge } from '@/components/ui/Badge';
import { Pagination } from '@/components/ui/Pagination';
import { EmptyState } from '@/components/ui/EmptyState';
import type { UnifiedContact } from '@/api/hooks';
import { useContactFolders } from '@/api/contacts';
import type { ContactFolder } from '@/api/contactFolders';
import type { SortState, SortCondition } from '@/components/contacts/SortPanel';
import type { GroupState, GroupedContacts } from '@/components/contacts/GroupPanel';
import { Loader2, ChevronRight, ChevronDown } from 'lucide-react';
import { Loader2, ChevronRight, ChevronDown, Settings, GripVertical, Info } from 'lucide-react';
export type ContactViewMode = 'list' | 'table' | 'cards';
@@ -315,6 +317,13 @@ export interface ContactListProps {
onSortStateChange?: (s: SortState) => void;
groupState?: GroupState;
groupedContacts?: GroupedContacts[];
// Bulk selection props
selectedContactIds?: Set<string>;
onSelectionChange?: (ids: Set<string>) => void;
// Bulk action callbacks
onBulkDelete?: (ids: string[]) => void;
onBulkAssignFolder?: (ids: string[], folderId: string) => void;
onBulkAddTags?: (ids: string[], tags: string[]) => void;
}
export function ContactList({
@@ -334,6 +343,11 @@ export function ContactList({
onSortStateChange,
groupState,
groupedContacts,
selectedContactIds,
onSelectionChange,
onBulkDelete,
onBulkAssignFolder,
onBulkAddTags,
}: ContactListProps) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
@@ -346,6 +360,166 @@ export function ContactList({
const resizeRef = useRef<{ key: string; startX: number; startWidth: number } | null>(null);
const dragColRef = useRef<string | null>(null);
// ── Contact folders for bulk assign (Feature 2) ──
const { data: folderData } = useContactFolders();
const folderList: ContactFolder[] = folderData ?? [];
// ── Column visibility dropdown state (Feature 1) ──
const [colMenuOpen, setColMenuOpen] = useState(false);
const colMenuRef = useRef<HTMLDivElement>(null);
// ── Custom sort drag-and-drop state (Feature 3) ──
const LS_CUSTOM_ORDER_KEY = 'contact-custom-order';
const [customOrder, setCustomOrder] = useState<string[]>(() => {
try {
const saved = localStorage.getItem(LS_CUSTOM_ORDER_KEY);
if (saved) return JSON.parse(saved);
} catch { /* ignore */ }
return [];
});
const dragRowRef = useRef<string | null>(null);
const isCustomSortActive = !sortState || sortState.conditions.length === 0;
// ── Bulk action bar state (Feature 2) ──
const [bulkDeleteConfirm, setBulkDeleteConfirm] = useState(false);
const [bulkFolderOpen, setBulkFolderOpen] = useState(false);
const [bulkTagsInput, setBulkTagsInput] = useState('');
const [bulkTagsOpen, setBulkTagsOpen] = useState(false);
const bulkFolderRef = useRef<HTMLDivElement>(null);
const bulkTagsRef = useRef<HTMLDivElement>(null);
// Close column menu on outside click
useEffect(() => {
if (!colMenuOpen) return;
const handler = (e: MouseEvent) => {
if (colMenuRef.current && !colMenuRef.current.contains(e.target as Node)) {
setColMenuOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [colMenuOpen]);
// Close bulk folder dropdown on outside click
useEffect(() => {
if (!bulkFolderOpen) return;
const handler = (e: MouseEvent) => {
if (bulkFolderRef.current && !bulkFolderRef.current.contains(e.target as Node)) {
setBulkFolderOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [bulkFolderOpen]);
// Close bulk tags dropdown on outside click
useEffect(() => {
if (!bulkTagsOpen) return;
const handler = (e: MouseEvent) => {
if (bulkTagsRef.current && !bulkTagsRef.current.contains(e.target as Node)) {
setBulkTagsOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [bulkTagsOpen]);
// ── Column visibility toggle (Feature 1) ──
const toggleColumnVisibility = useCallback((key: string) => {
if (key === 'checkbox') return; // checkbox always visible
setVisibleColumns((prev) => {
if (prev.includes(key)) {
const next = prev.filter((k) => k !== key);
localStorage.setItem(LS_VISIBLE_KEY, JSON.stringify(next));
return next;
} else {
const next = [...prev, key];
localStorage.setItem(LS_VISIBLE_KEY, JSON.stringify(next));
return next;
}
});
}, []);
// ── Bulk selection handlers (Feature 2) ──
const toggleContactSelection = useCallback((contactId: string) => {
if (!onSelectionChange) return;
const next = new Set(selectedContactIds || new Set<string>());
if (next.has(contactId)) {
next.delete(contactId);
} else {
next.add(contactId);
}
onSelectionChange(next);
}, [onSelectionChange, selectedContactIds]);
const toggleSelectAll = useCallback(() => {
if (!onSelectionChange) return;
const current = selectedContactIds || new Set<string>();
const allSelected = contacts.every((c) => current.has(c.id));
if (allSelected) {
const next = new Set(current);
contacts.forEach((c) => next.delete(c.id));
onSelectionChange(next);
} else {
const next = new Set(current);
contacts.forEach((c) => next.add(c.id));
onSelectionChange(next);
}
}, [onSelectionChange, selectedContactIds, contacts]);
const clearSelection = useCallback(() => {
if (onSelectionChange) onSelectionChange(new Set<string>());
}, [onSelectionChange]);
// ── Custom sort drag-and-drop handlers (Feature 3) ──
const applyCustomOrder = useCallback((list: UnifiedContact[]): UnifiedContact[] => {
if (!isCustomSortActive || customOrder.length === 0) return list;
const orderMap = new Map<string, number>();
customOrder.forEach((id, idx) => orderMap.set(id, idx));
const ordered = [...list];
ordered.sort((a, b) => {
const ai = orderMap.has(a.id) ? orderMap.get(a.id)! : Infinity;
const bi = orderMap.has(b.id) ? orderMap.get(b.id)! : Infinity;
return ai - bi;
});
return ordered;
}, [isCustomSortActive, customOrder]);
const handleRowDragStart = useCallback((e: React.DragEvent, contactId: string) => {
if (!isCustomSortActive) return;
dragRowRef.current = contactId;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', contactId);
}, [isCustomSortActive]);
const handleRowDragOver = useCallback((e: React.DragEvent) => {
if (!isCustomSortActive) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}, [isCustomSortActive]);
const handleRowDrop = useCallback((e: React.DragEvent, targetId: string) => {
if (!isCustomSortActive) return;
e.preventDefault();
e.stopPropagation();
const sourceId = dragRowRef.current;
if (!sourceId || sourceId === targetId) return;
const currentContacts = applyCustomOrder(contacts);
const sourceIdx = currentContacts.findIndex((c) => c.id === sourceId);
const targetIdx = currentContacts.findIndex((c) => c.id === targetId);
if (sourceIdx === -1 || targetIdx === -1) return;
const reordered = [...currentContacts];
const [moved] = reordered.splice(sourceIdx, 1);
reordered.splice(targetIdx, 0, moved);
const newOrder = reordered.map((c) => c.id);
setCustomOrder(newOrder);
localStorage.setItem(LS_CUSTOM_ORDER_KEY, JSON.stringify(newOrder));
dragRowRef.current = null;
}, [isCustomSortActive, contacts, applyCustomOrder]);
// Auto-skip virtualization for small datasets
const shouldVirtualize = contacts.length >= 50;
@@ -670,26 +844,48 @@ export function ContactList({
const renderContactRow = (contact: UnifiedContact) => (
<tr
key={contact.id}
draggable
draggable={isCustomSortActive}
onDragStart={(e) => {
if (isCustomSortActive) {
handleRowDragStart(e, contact.id);
} else {
e.dataTransfer.setData('text/plain', contact.id);
e.dataTransfer.effectAllowed = 'move';
}
}}
onDragOver={isCustomSortActive ? handleRowDragOver : undefined}
onDrop={isCustomSortActive ? (e) => handleRowDrop(e, contact.id) : undefined}
onClick={() => onSelectContact(contact)}
className={clsx(
'cursor-pointer transition-colors',
selectedContactId === contact.id ? 'bg-primary-50' : 'hover:bg-secondary-50',
isCustomSortActive && 'cursor-grab active:cursor-grabbing',
)}
aria-current={selectedContactId === contact.id ? 'true' : undefined}
>
{orderedVisibleColumns.map((col) => (
<td
key={col.key}
className="px-3 py-2 overflow-hidden text-ellipsis whitespace-nowrap"
className={clsx('px-3 py-2 overflow-hidden text-ellipsis whitespace-nowrap', isCustomSortActive && col.key !== 'checkbox' && 'flex items-center')}
style={{ width: getColWidth(col.key), maxWidth: getColWidth(col.key) }}
onClick={col.key === 'checkbox' ? (e) => e.stopPropagation() : undefined}
onClick={col.key === 'checkbox' ? (e) => { e.stopPropagation(); toggleContactSelection(contact.id); } : undefined}
>
{col.key === 'checkbox' ? (
<input
type="checkbox"
checked={selectedContactIds?.has(contact.id) || false}
onChange={() => {}}
className="rounded border-secondary-300"
aria-label={getDisplayName(contact)}
/>
) : isCustomSortActive && col.key === orderedVisibleColumns.find((c) => c.key !== 'checkbox')?.key ? (
<div className="flex items-center gap-1">
<GripVertical className="w-3 h-3 text-secondary-300 flex-shrink-0" />
{col.render(contact)}
</div>
) : (
col.render(contact)
)}
</td>
))}
</tr>
@@ -753,6 +949,133 @@ export function ContactList({
return (
<div className="flex flex-col h-full" data-testid="contact-table-view">
{/* Bulk action bar (Feature 2) */}
{selectedContactIds && selectedContactIds.size > 0 && (
<div className="sticky top-0 z-20 flex items-center gap-2 px-3 py-2 bg-primary-600 text-white shadow-md">
<span className="text-sm font-medium">{selectedContactIds.size} ausgewählt</span>
<div className="flex-1" />
{onBulkDelete && (
<button
onClick={() => setBulkDeleteConfirm(true)}
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-red-500 hover:bg-red-600 rounded transition-colors"
>
Löschen
</button>
)}
{onBulkAssignFolder && (
<div ref={bulkFolderRef} className="relative">
<button
onClick={() => setBulkFolderOpen(!bulkFolderOpen)}
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-white/20 hover:bg-white/30 rounded transition-colors"
>
Ordner zuweisen
</button>
{bulkFolderOpen && (
<div className="absolute right-0 top-full mt-1 bg-white text-secondary-800 rounded-lg shadow-lg border border-secondary-200 max-h-60 overflow-y-auto min-w-[200px] z-30">
{folderList.map((folder) => (
<button
key={folder.id}
onClick={() => {
onBulkAssignFolder(Array.from(selectedContactIds), folder.id);
setBulkFolderOpen(false);
clearSelection();
}}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-secondary-100 transition-colors"
>
{folder.name}
</button>
))}
{folderList.length === 0 && (
<div className="px-3 py-2 text-xs text-secondary-400">Keine Ordner</div>
)}
</div>
)}
</div>
)}
{onBulkAddTags && (
<div ref={bulkTagsRef} className="relative">
<button
onClick={() => setBulkTagsOpen(!bulkTagsOpen)}
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-white/20 hover:bg-white/30 rounded transition-colors"
>
Tags hinzufügen
</button>
{bulkTagsOpen && (
<div className="absolute right-0 top-full mt-1 bg-white text-secondary-800 rounded-lg shadow-lg border border-secondary-200 p-2 z-30">
<input
type="text"
value={bulkTagsInput}
onChange={(e) => setBulkTagsInput(e.target.value)}
placeholder="tag1, tag2, ..."
className="w-48 px-2 py-1 text-xs border border-secondary-200 rounded focus:outline-none focus:border-primary-400"
/>
<button
onClick={() => {
const tags = bulkTagsInput.split(',').map((t) => t.trim()).filter(Boolean);
if (tags.length > 0) {
onBulkAddTags(Array.from(selectedContactIds), tags);
setBulkTagsInput('');
setBulkTagsOpen(false);
clearSelection();
}
}}
className="w-full mt-1 px-2 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded transition-colors"
>
Hinzufügen
</button>
</div>
)}
</div>
)}
<button
onClick={clearSelection}
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-white/20 hover:bg-white/30 rounded transition-colors"
>
Auswahl aufheben
</button>
</div>
)}
{/* Bulk delete confirmation dialog (Feature 2) */}
{bulkDeleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-lg shadow-xl p-6 max-w-sm">
<h3 className="text-lg font-semibold text-secondary-900 mb-2">Löschen bestätigen</h3>
<p className="text-sm text-secondary-600 mb-4">
{selectedContactIds?.size || 0} Kontakt(e) wirklich löschen?
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setBulkDeleteConfirm(false)}
className="px-3 py-1.5 text-sm font-medium text-secondary-600 hover:bg-secondary-100 rounded transition-colors"
>
Abbrechen
</button>
<button
onClick={() => {
if (onBulkDelete && selectedContactIds) {
onBulkDelete(Array.from(selectedContactIds));
setBulkDeleteConfirm(false);
clearSelection();
}
}}
className="px-3 py-1.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded transition-colors"
>
Löschen
</button>
</div>
</div>
</div>
)}
{/* Custom sort info indicator (Feature 3) */}
{isCustomSortActive && customOrder.length > 0 && (
<div className="flex items-center gap-1.5 px-3 py-1 bg-amber-50 border-b border-amber-200 text-xs text-amber-700">
<Info className="w-3 h-3" />
<span>Custom Sortierung aktiv Drag-and-Drop zum Umsortieren</span>
</div>
)}
<div ref={scrollRef} className="flex-1 overflow-x-auto overflow-y-auto">
<table
className="text-sm"
@@ -784,7 +1107,13 @@ export function ContactList({
>
<div className="flex items-center">
{col.key === 'checkbox' ? (
<input type="checkbox" className="rounded border-secondary-300" aria-label={t('common.all')} />
<input
type="checkbox"
checked={selectedContactIds ? contacts.length > 0 && contacts.every((c) => selectedContactIds.has(c.id)) : false}
onChange={toggleSelectAll}
className="rounded border-secondary-300"
aria-label={t('common.all')}
/>
) : (
<>
<span className={clsx(col.sortable && 'cursor-pointer')}>
@@ -803,13 +1132,49 @@ export function ContactList({
</th>
);
})}
{/* Column visibility settings button (Feature 1) */}
<th className="relative px-2 py-2 w-10" style={{ width: 40 }}>
<div ref={colMenuRef} className="relative">
<button
onClick={() => setColMenuOpen(!colMenuOpen)}
title="Spalten verwalten"
aria-label="Spalten verwalten"
className="p-1 rounded hover:bg-secondary-100 text-secondary-500 hover:text-secondary-700 transition-colors"
>
<Settings className="w-3.5 h-3.5" />
</button>
{colMenuOpen && (
<div className="absolute right-0 top-full mt-1 bg-white rounded-lg shadow-lg border border-secondary-200 max-h-80 overflow-y-auto min-w-[200px] z-30">
<div className="px-3 py-2 text-xs font-semibold text-secondary-700 border-b border-secondary-100">Spalten verwalten</div>
{ALL_COLUMNS.map((col) => (
<label
key={col.key}
className={clsx(
'flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-secondary-50 cursor-pointer',
col.key === 'checkbox' && 'opacity-50 cursor-not-allowed',
)}
>
<input
type="checkbox"
checked={visibleColumns.includes(col.key)}
onChange={() => toggleColumnVisibility(col.key)}
disabled={col.key === 'checkbox'}
className="rounded border-secondary-300"
/>
<span>{col.label || 'Checkbox'}</span>
</label>
))}
</div>
)}
</div>
</th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-100">
{isGrouped ? (
renderGroupRows(groupTree)
) : (
contacts.map((contact) => renderContactRow(contact))
applyCustomOrder(contacts).map((contact) => renderContactRow(contact))
)}
</tbody>
</table>
@@ -6,6 +6,7 @@
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Filter, Plus, X, ChevronDown, Bookmark } from 'lucide-react';
import type { UnifiedContact } from '@/api/unifiedContacts';
import { useCustomFieldDefinitions, type CustomFieldDefinition } from '@/api/customFieldDefinitions';
// ─── Field definitions ───────────────────────────────────────────────────────
@@ -15,10 +16,10 @@ interface FilterFieldDef {
key: string;
label: string;
type: FieldType;
options?: { value: string; label: string }[]; group?: 'general' | 'company' | 'person' | 'address' | 'finance' | 'notes' | 'meta';
options?: { value: string; label: string }[]; group?: 'general' | 'company' | 'person' | 'address' | 'finance' | 'notes' | 'meta' | 'custom';
}
const FIELD_DEFS: FilterFieldDef[] = [
let FIELD_DEFS: FilterFieldDef[] = [
// General
{ key: 'displayname', label: 'Anzeigename', type: 'text', group: 'general' },
{ key: 'type', label: 'Typ', type: 'select', group: 'general', options: [
@@ -139,11 +140,16 @@ export const emptyFilterState: FilterState = {
// ─── Filter logic ─────────────────────────────────────────────────────────────
function getFieldValue(contact: UnifiedContact, field: string): any {
if (field.startsWith('custom.')) {
const customField = field.slice(7);
return contact.custom?.[customField];
}
return (contact as any)[field];
}
function matchesCondition(contact: UnifiedContact, cond: FilterCondition): boolean {
const def = FIELD_DEFS.find((f) => f.key === cond.field);
function matchesCondition(contact: UnifiedContact, cond: FilterCondition, allDefs?: FilterFieldDef[]): boolean {
const defs = allDefs || FIELD_DEFS;
const def = defs.find((f) => f.key === cond.field);
if (!def) return true;
const val = getFieldValue(contact, cond.field);
const op = cond.operator;
@@ -175,38 +181,39 @@ function matchesCondition(contact: UnifiedContact, cond: FilterCondition): boole
}
}
export function applyFilters(contacts: UnifiedContact[], filters: FilterState): UnifiedContact[] {
export function applyFilters(contacts: UnifiedContact[], filters: FilterState, allDefs?: FilterFieldDef[]): UnifiedContact[] {
if (!filters.conditions.length) return contacts;
if (filters.logic === 'AND') {
return contacts.filter((c) => filters.conditions.every((cond) => matchesCondition(c, cond)));
return contacts.filter((c) => filters.conditions.every((cond) => matchesCondition(c, cond, allDefs)));
} else {
return contacts.filter((c) => filters.conditions.some((cond) => matchesCondition(c, cond)));
return contacts.filter((c) => filters.conditions.some((cond) => matchesCondition(c, cond, allDefs)));
}
}
// ─── Context-sensitive field ordering ──────────────────────────────────────────
function getOrderedFields(contactType?: 'company' | 'person' | undefined): FilterFieldDef[] {
function getOrderedFields(contactType?: 'company' | 'person' | undefined, allDefs?: FilterFieldDef[]): FilterFieldDef[] {
const defs = allDefs || FIELD_DEFS;
if (contactType === 'company') {
// Company fields first, then general, then person, then rest
const order = ['general', 'company', 'address', 'finance', 'notes', 'meta', 'person'];
return [...FIELD_DEFS].sort((a, b) => {
const order = ['general', 'company', 'address', 'finance', 'notes', 'meta', 'custom', 'person'];
return [...defs].sort((a, b) => {
const ai = order.indexOf(a.group || 'general');
const bi = order.indexOf(b.group || 'general');
return ai - bi;
});
}
if (contactType === 'person') {
const order = ['general', 'person', 'address', 'notes', 'meta', 'company', 'finance'];
return [...FIELD_DEFS].sort((a, b) => {
const order = ['general', 'person', 'address', 'notes', 'meta', 'custom', 'company', 'finance'];
return [...defs].sort((a, b) => {
const ai = order.indexOf(a.group || 'general');
const bi = order.indexOf(b.group || 'general');
return ai - bi;
});
}
// All: general first, then alphabetical by group
const order = ['general', 'company', 'person', 'address', 'finance', 'notes', 'meta'];
return [...FIELD_DEFS].sort((a, b) => {
const order = ['general', 'company', 'person', 'address', 'finance', 'notes', 'meta', 'custom'];
return [...defs].sort((a, b) => {
const ai = order.indexOf(a.group || 'general');
const bi = order.indexOf(b.group || 'general');
return ai - bi;
@@ -242,6 +249,34 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
const panelRef = useRef<HTMLDivElement>(null);
const [panelPos, setPanelPos] = useState({ top: 0, left: 0 });
// Fetch custom field definitions (Feature 4)
const { data: customFieldDefs } = useCustomFieldDefinitions('contact');
// Merge custom field definitions into FIELD_DEFS
const allFieldDefs = useMemo(() => {
const baseFields = FIELD_DEFS.filter((f) => !f.key.startsWith('custom.'));
if (!customFieldDefs || customFieldDefs.items.length === 0) return baseFields;
const customFields: FilterFieldDef[] = customFieldDefs.items
.filter((def) => def.is_active)
.map((def) => {
const fieldType: FieldType =
def.field_type === 'number' ? 'number' :
def.field_type === 'date' ? 'date' :
def.field_type === 'select' || def.field_type === 'multiselect' ? 'select' :
'text';
return {
key: `custom.${def.name}`,
label: def.label || def.name,
type: fieldType,
group: 'custom' as const,
options: (def.field_type === 'select' || def.field_type === 'multiselect') && def.options
? def.options.map((opt) => ({ value: opt, label: opt }))
: undefined,
};
});
return [...baseFields, ...customFields];
}, [customFieldDefs]);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
@@ -256,12 +291,14 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
const handleToggle = () => {
if (!open && btnRef.current) {
const rect = btnRef.current.getBoundingClientRect();
setPanelPos({ top: rect.bottom + 4, left: rect.left });
const panelWidth = Math.min(480, window.innerWidth * 0.9);
const left = Math.min(rect.left, window.innerWidth - panelWidth - 10);
setPanelPos({ top: rect.bottom + 4, left: Math.max(10, left) });
}
setOpen(!open);
};
const orderedFields = useMemo(() => getOrderedFields(contactType), [contactType]);
const orderedFields = useMemo(() => getOrderedFields(contactType, allFieldDefs), [contactType, allFieldDefs]);
const activeCount = filters.conditions.length;
const addCondition = () => {
@@ -327,6 +364,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
finance: 'Finanzen',
notes: 'Notizen',
meta: 'Metadaten',
custom: 'Benutzerdefinierte Felder',
};
return (
@@ -361,7 +399,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
style={{
top: panelPos.top,
left: panelPos.left,
width: '480px',
width: 'min(480px, 90vw)',
maxHeight: '70vh',
overflowY: 'auto',
zIndex: 3000,
@@ -421,7 +459,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
{activeCount > 0 && (
<div className="flex flex-wrap gap-1.5 px-4 py-2 border-b border-secondary-100">
{filters.conditions.map((cond) => {
const def = FIELD_DEFS.find((f) => f.key === cond.field);
const def = allFieldDefs.find((f) => f.key === cond.field);
const op = getOperators(def?.type || 'text').find((o) => o.value === cond.operator);
return (
<span
@@ -475,12 +513,12 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
</div>
)}
{filters.conditions.map((cond, idx) => {
const def = FIELD_DEFS.find((f) => f.key === cond.field);
const def = allFieldDefs.find((f) => f.key === cond.field);
const operators = getOperators(def?.type || 'text');
const currentOp = operators.find((o) => o.value === cond.operator) || operators[0];
return (
<div key={cond.id} className="flex items-center gap-1.5">
<div key={cond.id} className="flex items-center gap-1.5 flex-wrap">
{/* Logic prefix */}
{idx > 0 && (
<span className="text-[10px] font-bold text-primary-600 w-8 text-center">
@@ -493,7 +531,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
<select
value={cond.field}
onChange={(e) => {
const newDef = FIELD_DEFS.find((f) => f.key === e.target.value);
const newDef = allFieldDefs.find((f) => f.key === e.target.value);
const newOps = getOperators(newDef?.type || 'text');
updateCondition(cond.id, {
field: e.target.value,
+50 -15
View File
@@ -6,6 +6,7 @@
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Group as GroupIcon, Plus, X } from 'lucide-react';
import type { UnifiedContact } from '@/api/unifiedContacts';
import { useCustomFieldDefinitions } from '@/api/customFieldDefinitions';
// ─── Field definitions ────────────────────────────────────────────────────────
@@ -15,10 +16,10 @@ interface GroupFieldDef {
key: string;
label: string;
type: FieldType;
group?: 'general' | 'company' | 'person' | 'address' | 'notes' | 'meta';
group?: 'general' | 'company' | 'person' | 'address' | 'notes' | 'meta' | 'custom';
}
const GROUP_FIELDS: GroupFieldDef[] = [
let GROUP_FIELDS: GroupFieldDef[] = [
// General
{ key: 'type', label: 'Typ', type: 'select', group: 'general' },
{ key: 'displayname', label: 'Anzeigename', type: 'text', group: 'general' },
@@ -84,6 +85,10 @@ export const emptyGroupState: GroupState = {
// ─── Group logic ──────────────────────────────────────────────────────────────
function getFieldValue(contact: UnifiedContact, field: string): any {
if (field.startsWith('custom.')) {
const customField = field.slice(7);
return contact.custom?.[customField];
}
return (contact as any)[field];
}
@@ -93,7 +98,8 @@ export interface GroupedContacts {
contacts: UnifiedContact[];
}
export function applyGrouping(contacts: UnifiedContact[], groupState: GroupState): GroupedContacts[] {
export function applyGrouping(contacts: UnifiedContact[], groupState: GroupState, allDefs?: GroupFieldDef[]): GroupedContacts[] {
const defs = allDefs || GROUP_FIELDS;
if (!groupState.conditions.length) {
return [{ key: 'all', label: 'Alle', contacts }];
}
@@ -102,7 +108,7 @@ export function applyGrouping(contacts: UnifiedContact[], groupState: GroupState
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);
const def = defs.find((f) => f.key === cond.field);
if (!def) continue;
const newGroups: GroupedContacts[] = [];
@@ -137,17 +143,18 @@ export function applyGrouping(contacts: UnifiedContact[], groupState: GroupState
// ─── Context-sensitive field ordering ──────────────────────────────────────────
function getOrderedFields(contactType?: 'company' | 'person' | undefined): GroupFieldDef[] {
function getOrderedFields(contactType?: 'company' | 'person' | undefined, allDefs?: GroupFieldDef[]): GroupFieldDef[] {
const defs = allDefs || GROUP_FIELDS;
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'));
const order = ['general', 'company', 'address', 'notes', 'meta', 'custom', 'person'];
return [...defs].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', 'person', 'address', 'notes', 'meta', 'custom', 'company'];
return [...defs].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'));
const order = ['general', 'company', 'person', 'address', 'notes', 'meta', 'custom'];
return [...defs].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
}
// ─── Component ────────────────────────────────────────────────────────────────
@@ -169,6 +176,31 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
const panelRef = useRef<HTMLDivElement>(null);
const [panelPos, setPanelPos] = useState({ top: 0, left: 0 });
// Fetch custom field definitions (Feature 4)
const { data: customFieldDefs } = useCustomFieldDefinitions('contact');
// Merge custom field definitions into GROUP_FIELDS
const allGroupFields = useMemo(() => {
const baseFields = GROUP_FIELDS.filter((f) => !f.key.startsWith('custom.'));
if (!customFieldDefs || customFieldDefs.items.length === 0) return baseFields;
const customFields: GroupFieldDef[] = customFieldDefs.items
.filter((def) => def.is_active)
.map((def) => {
const fieldType: FieldType =
def.field_type === 'number' ? 'number' :
def.field_type === 'date' ? 'date' :
def.field_type === 'select' || def.field_type === 'multiselect' ? 'select' :
'text';
return {
key: `custom.${def.name}`,
label: def.label || def.name,
type: fieldType,
group: 'custom' as const,
};
});
return [...baseFields, ...customFields];
}, [customFieldDefs]);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
@@ -183,12 +215,14 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
const handleToggle = () => {
if (!open && btnRef.current) {
const rect = btnRef.current.getBoundingClientRect();
setPanelPos({ top: rect.bottom + 4, left: rect.left });
const panelWidth = Math.min(420, window.innerWidth * 0.9);
const left = Math.min(rect.left, window.innerWidth - panelWidth - 10);
setPanelPos({ top: rect.bottom + 4, left: Math.max(10, left) });
}
setOpen(!open);
};
const orderedFields = useMemo(() => getOrderedFields(contactType), [contactType]);
const orderedFields = useMemo(() => getOrderedFields(contactType, allGroupFields), [contactType, allGroupFields]);
const activeCount = groupState.conditions.length;
const addCondition = () => {
@@ -246,6 +280,7 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
address: 'Adresse',
notes: 'Notizen',
meta: 'Metadaten',
custom: 'Benutzerdefinierte Felder',
};
return (
@@ -280,7 +315,7 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
style={{
top: panelPos.top,
left: panelPos.left,
width: '420px',
width: 'min(420px, 90vw)',
maxHeight: '70vh',
overflowY: 'auto',
zIndex: 3000,
@@ -320,7 +355,7 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
{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);
const def = allGroupFields.find((f) => f.key === cond.field);
return (
<span
key={cond.id}
+51 -16
View File
@@ -6,6 +6,7 @@
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { ArrowDownAZ, ArrowUpZA, Plus, X } from 'lucide-react';
import type { UnifiedContact } from '@/api/unifiedContacts';
import { useCustomFieldDefinitions } from '@/api/customFieldDefinitions';
// ─── Field definitions (reuse from FilterPanel) ────────────────────────────────
@@ -15,10 +16,10 @@ interface SortFieldDef {
key: string;
label: string;
type: FieldType;
group?: 'general' | 'company' | 'person' | 'address' | 'notes' | 'meta';
group?: 'general' | 'company' | 'person' | 'address' | 'notes' | 'meta' | 'custom';
}
const SORT_FIELDS: SortFieldDef[] = [
let SORT_FIELDS: SortFieldDef[] = [
// General
{ key: 'displayname', label: 'Anzeigename', type: 'text', group: 'general' },
{ key: 'type', label: 'Typ', type: 'select', group: 'general' },
@@ -86,6 +87,10 @@ export const emptySortState: SortState = {
// ─── Sort logic ───────────────────────────────────────────────────────────────
function getFieldValue(contact: UnifiedContact, field: string): any {
if (field.startsWith('custom.')) {
const customField = field.slice(7);
return contact.custom?.[customField];
}
return (contact as any)[field];
}
@@ -109,13 +114,14 @@ function compareValues(a: any, b: any, fieldType: FieldType): number {
return 0;
}
export function applySorting(contacts: UnifiedContact[], sortState: SortState): UnifiedContact[] {
export function applySorting(contacts: UnifiedContact[], sortState: SortState, allDefs?: SortFieldDef[]): UnifiedContact[] {
if (!sortState.conditions.length) return contacts;
const defs = allDefs || SORT_FIELDS;
const sorted = [...contacts];
sorted.sort((a, b) => {
for (const cond of sortState.conditions) {
const def = SORT_FIELDS.find((f) => f.key === cond.field);
const def = defs.find((f) => f.key === cond.field);
if (!def) continue;
const cmp = compareValues(getFieldValue(a, cond.field), getFieldValue(b, cond.field), def.type);
if (cmp !== 0) {
@@ -129,17 +135,18 @@ export function applySorting(contacts: UnifiedContact[], sortState: SortState):
// ─── Context-sensitive field ordering ──────────────────────────────────────────
function getOrderedFields(contactType?: 'company' | 'person' | undefined): SortFieldDef[] {
function getOrderedFields(contactType?: 'company' | 'person' | undefined, allDefs?: SortFieldDef[]): SortFieldDef[] {
const defs = allDefs || SORT_FIELDS;
if (contactType === 'company') {
const order = ['general', 'company', 'address', 'notes', 'meta', 'person'];
return [...SORT_FIELDS].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
const order = ['general', 'company', 'address', 'notes', 'meta', 'custom', 'person'];
return [...defs].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 [...SORT_FIELDS].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
const order = ['general', 'person', 'address', 'notes', 'meta', 'custom', 'company'];
return [...defs].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
}
const order = ['general', 'company', 'person', 'address', 'notes', 'meta'];
return [...SORT_FIELDS].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
const order = ['general', 'company', 'person', 'address', 'notes', 'meta', 'custom'];
return [...defs].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
}
// ─── Component ────────────────────────────────────────────────────────────────
@@ -161,6 +168,31 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
const panelRef = useRef<HTMLDivElement>(null);
const [panelPos, setPanelPos] = useState({ top: 0, left: 0 });
// Fetch custom field definitions (Feature 4)
const { data: customFieldDefs } = useCustomFieldDefinitions('contact');
// Merge custom field definitions into SORT_FIELDS
const allSortFields = useMemo(() => {
const baseFields = SORT_FIELDS.filter((f) => !f.key.startsWith('custom.'));
if (!customFieldDefs || customFieldDefs.items.length === 0) return baseFields;
const customFields: SortFieldDef[] = customFieldDefs.items
.filter((def) => def.is_active)
.map((def) => {
const fieldType: FieldType =
def.field_type === 'number' ? 'number' :
def.field_type === 'date' ? 'date' :
def.field_type === 'select' || def.field_type === 'multiselect' ? 'select' :
'text';
return {
key: `custom.${def.name}`,
label: def.label || def.name,
type: fieldType,
group: 'custom' as const,
};
});
return [...baseFields, ...customFields];
}, [customFieldDefs]);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
@@ -175,12 +207,14 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
const handleToggle = () => {
if (!open && btnRef.current) {
const rect = btnRef.current.getBoundingClientRect();
setPanelPos({ top: rect.bottom + 4, left: rect.left });
const panelWidth = Math.min(420, window.innerWidth * 0.9);
const left = Math.min(rect.left, window.innerWidth - panelWidth - 10);
setPanelPos({ top: rect.bottom + 4, left: Math.max(10, left) });
}
setOpen(!open);
};
const orderedFields = useMemo(() => getOrderedFields(contactType), [contactType]);
const orderedFields = useMemo(() => getOrderedFields(contactType, allSortFields), [contactType, allSortFields]);
const activeCount = sortState.conditions.length;
const addCondition = () => {
@@ -239,6 +273,7 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
address: 'Adresse',
notes: 'Notizen',
meta: 'Metadaten',
custom: 'Benutzerdefinierte Felder',
};
return (
@@ -273,7 +308,7 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
style={{
top: panelPos.top,
left: panelPos.left,
width: '420px',
width: 'min(420px, 90vw)',
maxHeight: '70vh',
overflowY: 'auto',
zIndex: 3000,
@@ -313,7 +348,7 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
{activeCount > 0 && (
<div className="flex flex-wrap gap-1.5 px-4 py-2 border-b border-secondary-100">
{sortState.conditions.map((cond, idx) => {
const def = SORT_FIELDS.find((f) => f.key === cond.field);
const def = allSortFields.find((f) => f.key === cond.field);
return (
<span
key={cond.id}
@@ -334,7 +369,7 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
{/* Sort rows */}
<div className="px-4 py-3 space-y-2">
{sortState.conditions.map((cond, idx) => {
const def = SORT_FIELDS.find((f) => f.key === cond.field);
const def = allSortFields.find((f) => f.key === cond.field);
return (
<div key={cond.id} className="flex items-center gap-1.5">
{/* Priority number + reorder */}
+67 -3
View File
@@ -29,8 +29,12 @@ import { useSavedFilters as useSavedFiltersApi, useCreateSavedFilter, useDeleteS
import {
useUnifiedContacts,
useUnifiedContact,
useDeleteUnifiedContact,
useUpdateUnifiedContact,
type UnifiedContact,
} from '@/api/hooks';
import { useContactFolders } from '@/api/contacts';
import { useCustomFieldDefinitions } from '@/api/customFieldDefinitions';
const PAGE_SIZE = 25;
@@ -56,8 +60,31 @@ export function ContactsListPage() {
const [multiSelectFolders, setMultiSelectFolders] = useState<string[]>([]);
const [activeViewId, setActiveViewId] = useState<string | null>(null);
const [saveViewDialogOpen, setSaveViewDialogOpen] = useState(false);
const [selectedContactIds, setSelectedContactIds] = useState<Set<string>>(new Set());
const openWindow = useWindowStore((s) => s.openWindow);
// Bulk action mutations
const deleteContactMut = useDeleteUnifiedContact();
const updateContactMut = useUpdateUnifiedContact();
const { data: folders } = useContactFolders();
const { data: customFieldDefs } = useCustomFieldDefinitions('contact');
// Build custom field defs for filter/sort/group functions
const customDefsForFunctions = useMemo(() => {
if (!customFieldDefs || customFieldDefs.items.length === 0) return undefined;
return customFieldDefs.items
.filter((def) => def.is_active)
.map((def) => ({
key: `custom.${def.name}`,
label: def.label || def.name,
type: (def.field_type === 'number' ? 'number' : def.field_type === 'date' ? 'date' : (def.field_type === 'select' || def.field_type === 'multiselect') ? 'select' : 'text') as any,
group: 'custom' as any,
options: (def.field_type === 'select' || def.field_type === 'multiselect') && def.options
? def.options.map((opt: string) => ({ value: opt, label: opt }))
: undefined,
}));
}, [customFieldDefs]);
// Saved views from API (durable, per-user)
const { data: apiSavedViews } = useSavedViews('contacts');
const createViewMut = useCreateSavedView();
@@ -144,14 +171,14 @@ export function ContactsListPage() {
});
}
// Apply FilterPanel conditions
result = applyFilters(result, filterState);
result = applyFilters(result, filterState, customDefsForFunctions);
// Apply SortPanel sorting
result = applySorting(result, sortState);
result = applySorting(result, sortState, customDefsForFunctions);
return result;
}, [contacts, tagFilter, filterState, sortState, multiSelectFolders]);
// Apply GroupPanel grouping
const groupedContacts = useMemo(() => applyGrouping(filteredContacts, groupState), [filteredContacts, groupState]);
const groupedContacts = useMemo(() => applyGrouping(filteredContacts, groupState, customDefsForFunctions), [filteredContacts, groupState, customDefsForFunctions]);
// Handle folder selection
const handleSelectFilter = useCallback((filter: ContactFilter) => {
@@ -221,6 +248,33 @@ export function ContactsListPage() {
setActiveView('list');
}, []);
// Bulk delete handler (Feature 2)
const handleBulkDelete = useCallback((ids: string[]) => {
ids.forEach((id) => {
deleteContactMut.mutate({ id });
});
setSelectedContactIds(new Set());
}, [deleteContactMut]);
// Bulk assign folder handler (Feature 2)
const handleBulkAssignFolder = useCallback((ids: string[], folderId: string) => {
ids.forEach((id) => {
updateContactMut.mutate({ id, data: { folder_id: folderId } });
});
setSelectedContactIds(new Set());
}, [updateContactMut]);
// Bulk add tags handler (Feature 2)
const handleBulkAddTags = useCallback((ids: string[], tags: string[]) => {
ids.forEach((id) => {
const contact = contacts.find((c) => c.id === id);
const existingTags = contact?.tags ? contact.tags.split(',').map((t) => t.trim()).filter(Boolean) : [];
const newTags = [...new Set([...existingTags, ...tags])];
updateContactMut.mutate({ id, data: { tags: newTags.join(', ') } });
});
setSelectedContactIds(new Set());
}, [updateContactMut, contacts]);
// Save current view configuration as a custom view — opens dialog
const handleSaveView = useCallback(() => {
setSaveViewDialogOpen(true);
@@ -585,6 +639,11 @@ export function ContactsListPage() {
onSortStateChange={setSortState}
groupState={groupState}
groupedContacts={groupedContacts}
selectedContactIds={selectedContactIds}
onSelectionChange={setSelectedContactIds}
onBulkDelete={handleBulkDelete}
onBulkAssignFolder={handleBulkAssignFolder}
onBulkAddTags={handleBulkAddTags}
/>
</div>
</ResizablePanel>
@@ -662,6 +721,11 @@ export function ContactsListPage() {
onSortStateChange={setSortState}
groupState={groupState}
groupedContacts={groupedContacts}
selectedContactIds={selectedContactIds}
onSelectionChange={setSelectedContactIds}
onBulkDelete={handleBulkDelete}
onBulkAssignFolder={handleBulkAssignFolder}
onBulkAddTags={handleBulkAddTags}
/>
</div>
)}