feat: column visibility, bulk actions, custom sort drag-drop, custom fields in filter/sort/group, mobile optimization
This commit is contained in:
@@ -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) => {
|
||||
e.dataTransfer.setData('text/plain', contact.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
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.render(contact)}
|
||||
{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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user