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
+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}