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