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 { Pagination } from '@/components/ui/Pagination';
import { EmptyState } from '@/components/ui/EmptyState'; import { EmptyState } from '@/components/ui/EmptyState';
import type { UnifiedContact } from '@/api/hooks'; 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 { SortState, SortCondition } from '@/components/contacts/SortPanel';
import type { GroupState, GroupedContacts } from '@/components/contacts/GroupPanel'; 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'; export type ContactViewMode = 'list' | 'table' | 'cards';
@@ -315,6 +317,13 @@ export interface ContactListProps {
onSortStateChange?: (s: SortState) => void; onSortStateChange?: (s: SortState) => void;
groupState?: GroupState; groupState?: GroupState;
groupedContacts?: GroupedContacts[]; 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({ export function ContactList({
@@ -334,6 +343,11 @@ export function ContactList({
onSortStateChange, onSortStateChange,
groupState, groupState,
groupedContacts, groupedContacts,
selectedContactIds,
onSelectionChange,
onBulkDelete,
onBulkAssignFolder,
onBulkAddTags,
}: ContactListProps) { }: ContactListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
@@ -346,6 +360,166 @@ export function ContactList({
const resizeRef = useRef<{ key: string; startX: number; startWidth: number } | null>(null); const resizeRef = useRef<{ key: string; startX: number; startWidth: number } | null>(null);
const dragColRef = useRef<string | 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 // Auto-skip virtualization for small datasets
const shouldVirtualize = contacts.length >= 50; const shouldVirtualize = contacts.length >= 50;
@@ -670,26 +844,48 @@ export function ContactList({
const renderContactRow = (contact: UnifiedContact) => ( const renderContactRow = (contact: UnifiedContact) => (
<tr <tr
key={contact.id} key={contact.id}
draggable draggable={isCustomSortActive}
onDragStart={(e) => { onDragStart={(e) => {
e.dataTransfer.setData('text/plain', contact.id); if (isCustomSortActive) {
e.dataTransfer.effectAllowed = 'move'; 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)} onClick={() => onSelectContact(contact)}
className={clsx( className={clsx(
'cursor-pointer transition-colors', 'cursor-pointer transition-colors',
selectedContactId === contact.id ? 'bg-primary-50' : 'hover:bg-secondary-50', selectedContactId === contact.id ? 'bg-primary-50' : 'hover:bg-secondary-50',
isCustomSortActive && 'cursor-grab active:cursor-grabbing',
)} )}
aria-current={selectedContactId === contact.id ? 'true' : undefined} aria-current={selectedContactId === contact.id ? 'true' : undefined}
> >
{orderedVisibleColumns.map((col) => ( {orderedVisibleColumns.map((col) => (
<td <td
key={col.key} 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) }} 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> </td>
))} ))}
</tr> </tr>
@@ -753,6 +949,133 @@ export function ContactList({
return ( return (
<div className="flex flex-col h-full" data-testid="contact-table-view"> <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"> <div ref={scrollRef} className="flex-1 overflow-x-auto overflow-y-auto">
<table <table
className="text-sm" className="text-sm"
@@ -784,7 +1107,13 @@ export function ContactList({
> >
<div className="flex items-center"> <div className="flex items-center">
{col.key === 'checkbox' ? ( {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')}> <span className={clsx(col.sortable && 'cursor-pointer')}>
@@ -803,13 +1132,49 @@ export function ContactList({
</th> </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> </tr>
</thead> </thead>
<tbody className="divide-y divide-secondary-100"> <tbody className="divide-y divide-secondary-100">
{isGrouped ? ( {isGrouped ? (
renderGroupRows(groupTree) renderGroupRows(groupTree)
) : ( ) : (
contacts.map((contact) => renderContactRow(contact)) applyCustomOrder(contacts).map((contact) => renderContactRow(contact))
)} )}
</tbody> </tbody>
</table> </table>
@@ -6,6 +6,7 @@
import React, { useState, useRef, useEffect, useMemo } from 'react'; import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Filter, Plus, X, ChevronDown, Bookmark } from 'lucide-react'; import { Filter, Plus, X, ChevronDown, Bookmark } from 'lucide-react';
import type { UnifiedContact } from '@/api/unifiedContacts'; import type { UnifiedContact } from '@/api/unifiedContacts';
import { useCustomFieldDefinitions, type CustomFieldDefinition } from '@/api/customFieldDefinitions';
// ─── Field definitions ─────────────────────────────────────────────────────── // ─── Field definitions ───────────────────────────────────────────────────────
@@ -15,10 +16,10 @@ interface FilterFieldDef {
key: string; key: string;
label: string; label: string;
type: FieldType; 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 // General
{ key: 'displayname', label: 'Anzeigename', type: 'text', group: 'general' }, { key: 'displayname', label: 'Anzeigename', type: 'text', group: 'general' },
{ key: 'type', label: 'Typ', type: 'select', group: 'general', options: [ { key: 'type', label: 'Typ', type: 'select', group: 'general', options: [
@@ -139,11 +140,16 @@ export const emptyFilterState: FilterState = {
// ─── Filter logic ───────────────────────────────────────────────────────────── // ─── Filter logic ─────────────────────────────────────────────────────────────
function getFieldValue(contact: UnifiedContact, field: string): any { 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]; return (contact as any)[field];
} }
function matchesCondition(contact: UnifiedContact, cond: FilterCondition): boolean { function matchesCondition(contact: UnifiedContact, cond: FilterCondition, allDefs?: FilterFieldDef[]): boolean {
const def = FIELD_DEFS.find((f) => f.key === cond.field); const defs = allDefs || FIELD_DEFS;
const def = defs.find((f) => f.key === cond.field);
if (!def) return true; if (!def) return true;
const val = getFieldValue(contact, cond.field); const val = getFieldValue(contact, cond.field);
const op = cond.operator; 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.conditions.length) return contacts;
if (filters.logic === 'AND') { 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 { } 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 ────────────────────────────────────────── // ─── 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') { if (contactType === 'company') {
// Company fields first, then general, then person, then rest // Company fields first, then general, then person, then rest
const order = ['general', 'company', 'address', 'finance', 'notes', 'meta', 'person']; const order = ['general', 'company', 'address', 'finance', 'notes', 'meta', 'custom', 'person'];
return [...FIELD_DEFS].sort((a, b) => { return [...defs].sort((a, b) => {
const ai = order.indexOf(a.group || 'general'); const ai = order.indexOf(a.group || 'general');
const bi = order.indexOf(b.group || 'general'); const bi = order.indexOf(b.group || 'general');
return ai - bi; return ai - bi;
}); });
} }
if (contactType === 'person') { if (contactType === 'person') {
const order = ['general', 'person', 'address', 'notes', 'meta', 'company', 'finance']; const order = ['general', 'person', 'address', 'notes', 'meta', 'custom', 'company', 'finance'];
return [...FIELD_DEFS].sort((a, b) => { return [...defs].sort((a, b) => {
const ai = order.indexOf(a.group || 'general'); const ai = order.indexOf(a.group || 'general');
const bi = order.indexOf(b.group || 'general'); const bi = order.indexOf(b.group || 'general');
return ai - bi; return ai - bi;
}); });
} }
// All: general first, then alphabetical by group // All: general first, then alphabetical by group
const order = ['general', 'company', 'person', 'address', 'finance', 'notes', 'meta']; const order = ['general', 'company', 'person', 'address', 'finance', 'notes', 'meta', 'custom'];
return [...FIELD_DEFS].sort((a, b) => { return [...defs].sort((a, b) => {
const ai = order.indexOf(a.group || 'general'); const ai = order.indexOf(a.group || 'general');
const bi = order.indexOf(b.group || 'general'); const bi = order.indexOf(b.group || 'general');
return ai - bi; return ai - bi;
@@ -242,6 +249,34 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const [panelPos, setPanelPos] = useState({ top: 0, left: 0 }); 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(() => { useEffect(() => {
if (!open) return; if (!open) return;
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
@@ -256,12 +291,14 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
const handleToggle = () => { const handleToggle = () => {
if (!open && btnRef.current) { if (!open && btnRef.current) {
const rect = btnRef.current.getBoundingClientRect(); 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); setOpen(!open);
}; };
const orderedFields = useMemo(() => getOrderedFields(contactType), [contactType]); const orderedFields = useMemo(() => getOrderedFields(contactType, allFieldDefs), [contactType, allFieldDefs]);
const activeCount = filters.conditions.length; const activeCount = filters.conditions.length;
const addCondition = () => { const addCondition = () => {
@@ -327,6 +364,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
finance: 'Finanzen', finance: 'Finanzen',
notes: 'Notizen', notes: 'Notizen',
meta: 'Metadaten', meta: 'Metadaten',
custom: 'Benutzerdefinierte Felder',
}; };
return ( return (
@@ -361,7 +399,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
style={{ style={{
top: panelPos.top, top: panelPos.top,
left: panelPos.left, left: panelPos.left,
width: '480px', width: 'min(480px, 90vw)',
maxHeight: '70vh', maxHeight: '70vh',
overflowY: 'auto', overflowY: 'auto',
zIndex: 3000, zIndex: 3000,
@@ -421,7 +459,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
{activeCount > 0 && ( {activeCount > 0 && (
<div className="flex flex-wrap gap-1.5 px-4 py-2 border-b border-secondary-100"> <div className="flex flex-wrap gap-1.5 px-4 py-2 border-b border-secondary-100">
{filters.conditions.map((cond) => { {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); const op = getOperators(def?.type || 'text').find((o) => o.value === cond.operator);
return ( return (
<span <span
@@ -475,12 +513,12 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
</div> </div>
)} )}
{filters.conditions.map((cond, idx) => { {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 operators = getOperators(def?.type || 'text');
const currentOp = operators.find((o) => o.value === cond.operator) || operators[0]; const currentOp = operators.find((o) => o.value === cond.operator) || operators[0];
return ( 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 */} {/* Logic prefix */}
{idx > 0 && ( {idx > 0 && (
<span className="text-[10px] font-bold text-primary-600 w-8 text-center"> <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 <select
value={cond.field} value={cond.field}
onChange={(e) => { 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'); const newOps = getOperators(newDef?.type || 'text');
updateCondition(cond.id, { updateCondition(cond.id, {
field: e.target.value, field: e.target.value,
+50 -15
View File
@@ -6,6 +6,7 @@
import React, { useState, useRef, useEffect, useMemo } from 'react'; import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Group as GroupIcon, Plus, X } from 'lucide-react'; import { Group as GroupIcon, Plus, X } from 'lucide-react';
import type { UnifiedContact } from '@/api/unifiedContacts'; import type { UnifiedContact } from '@/api/unifiedContacts';
import { useCustomFieldDefinitions } from '@/api/customFieldDefinitions';
// ─── Field definitions ──────────────────────────────────────────────────────── // ─── Field definitions ────────────────────────────────────────────────────────
@@ -15,10 +16,10 @@ interface GroupFieldDef {
key: string; key: string;
label: string; label: string;
type: FieldType; 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 // General
{ key: 'type', label: 'Typ', type: 'select', group: 'general' }, { key: 'type', label: 'Typ', type: 'select', group: 'general' },
{ key: 'displayname', label: 'Anzeigename', type: 'text', group: 'general' }, { key: 'displayname', label: 'Anzeigename', type: 'text', group: 'general' },
@@ -84,6 +85,10 @@ export const emptyGroupState: GroupState = {
// ─── Group logic ────────────────────────────────────────────────────────────── // ─── Group logic ──────────────────────────────────────────────────────────────
function getFieldValue(contact: UnifiedContact, field: string): any { 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]; return (contact as any)[field];
} }
@@ -93,7 +98,8 @@ export interface GroupedContacts {
contacts: UnifiedContact[]; 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) { if (!groupState.conditions.length) {
return [{ key: 'all', label: 'Alle', contacts }]; 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] }]; let groups: GroupedContacts[] = [{ key: 'all', label: 'Alle', contacts: [...contacts] }];
for (const cond of groupState.conditions) { 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; if (!def) continue;
const newGroups: GroupedContacts[] = []; const newGroups: GroupedContacts[] = [];
@@ -137,17 +143,18 @@ export function applyGrouping(contacts: UnifiedContact[], groupState: GroupState
// ─── Context-sensitive field ordering ────────────────────────────────────────── // ─── 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') { if (contactType === 'company') {
const order = ['general', 'company', 'address', 'notes', 'meta', 'person']; const order = ['general', 'company', 'address', 'notes', 'meta', 'custom', 'person'];
return [...GROUP_FIELDS].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general')); return [...defs].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
} }
if (contactType === 'person') { if (contactType === 'person') {
const order = ['general', 'person', 'address', 'notes', 'meta', 'company']; const order = ['general', 'person', 'address', 'notes', 'meta', 'custom', 'company'];
return [...GROUP_FIELDS].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general')); return [...defs].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
} }
const order = ['general', 'company', 'person', 'address', 'notes', 'meta']; const order = ['general', 'company', 'person', 'address', 'notes', 'meta', 'custom'];
return [...GROUP_FIELDS].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general')); return [...defs].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
} }
// ─── Component ──────────────────────────────────────────────────────────────── // ─── Component ────────────────────────────────────────────────────────────────
@@ -169,6 +176,31 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const [panelPos, setPanelPos] = useState({ top: 0, left: 0 }); 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(() => { useEffect(() => {
if (!open) return; if (!open) return;
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
@@ -183,12 +215,14 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
const handleToggle = () => { const handleToggle = () => {
if (!open && btnRef.current) { if (!open && btnRef.current) {
const rect = btnRef.current.getBoundingClientRect(); 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); setOpen(!open);
}; };
const orderedFields = useMemo(() => getOrderedFields(contactType), [contactType]); const orderedFields = useMemo(() => getOrderedFields(contactType, allGroupFields), [contactType, allGroupFields]);
const activeCount = groupState.conditions.length; const activeCount = groupState.conditions.length;
const addCondition = () => { const addCondition = () => {
@@ -246,6 +280,7 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
address: 'Adresse', address: 'Adresse',
notes: 'Notizen', notes: 'Notizen',
meta: 'Metadaten', meta: 'Metadaten',
custom: 'Benutzerdefinierte Felder',
}; };
return ( return (
@@ -280,7 +315,7 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
style={{ style={{
top: panelPos.top, top: panelPos.top,
left: panelPos.left, left: panelPos.left,
width: '420px', width: 'min(420px, 90vw)',
maxHeight: '70vh', maxHeight: '70vh',
overflowY: 'auto', overflowY: 'auto',
zIndex: 3000, zIndex: 3000,
@@ -320,7 +355,7 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
{activeCount > 0 && ( {activeCount > 0 && (
<div className="flex flex-wrap gap-1.5 px-4 py-2 border-b border-secondary-100"> <div className="flex flex-wrap gap-1.5 px-4 py-2 border-b border-secondary-100">
{groupState.conditions.map((cond, idx) => { {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 ( return (
<span <span
key={cond.id} key={cond.id}
+51 -16
View File
@@ -6,6 +6,7 @@
import React, { useState, useRef, useEffect, useMemo } from 'react'; import React, { useState, useRef, useEffect, useMemo } from 'react';
import { ArrowDownAZ, ArrowUpZA, Plus, X } from 'lucide-react'; import { ArrowDownAZ, ArrowUpZA, Plus, X } from 'lucide-react';
import type { UnifiedContact } from '@/api/unifiedContacts'; import type { UnifiedContact } from '@/api/unifiedContacts';
import { useCustomFieldDefinitions } from '@/api/customFieldDefinitions';
// ─── Field definitions (reuse from FilterPanel) ──────────────────────────────── // ─── Field definitions (reuse from FilterPanel) ────────────────────────────────
@@ -15,10 +16,10 @@ interface SortFieldDef {
key: string; key: string;
label: string; label: string;
type: FieldType; 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 // General
{ key: 'displayname', label: 'Anzeigename', type: 'text', group: 'general' }, { key: 'displayname', label: 'Anzeigename', type: 'text', group: 'general' },
{ key: 'type', label: 'Typ', type: 'select', group: 'general' }, { key: 'type', label: 'Typ', type: 'select', group: 'general' },
@@ -86,6 +87,10 @@ export const emptySortState: SortState = {
// ─── Sort logic ─────────────────────────────────────────────────────────────── // ─── Sort logic ───────────────────────────────────────────────────────────────
function getFieldValue(contact: UnifiedContact, field: string): any { 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]; return (contact as any)[field];
} }
@@ -109,13 +114,14 @@ function compareValues(a: any, b: any, fieldType: FieldType): number {
return 0; 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; if (!sortState.conditions.length) return contacts;
const defs = allDefs || SORT_FIELDS;
const sorted = [...contacts]; const sorted = [...contacts];
sorted.sort((a, b) => { sorted.sort((a, b) => {
for (const cond of sortState.conditions) { 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; if (!def) continue;
const cmp = compareValues(getFieldValue(a, cond.field), getFieldValue(b, cond.field), def.type); const cmp = compareValues(getFieldValue(a, cond.field), getFieldValue(b, cond.field), def.type);
if (cmp !== 0) { if (cmp !== 0) {
@@ -129,17 +135,18 @@ export function applySorting(contacts: UnifiedContact[], sortState: SortState):
// ─── Context-sensitive field ordering ────────────────────────────────────────── // ─── 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') { if (contactType === 'company') {
const order = ['general', 'company', 'address', 'notes', 'meta', 'person']; const order = ['general', 'company', 'address', 'notes', 'meta', 'custom', 'person'];
return [...SORT_FIELDS].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general')); return [...defs].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
} }
if (contactType === 'person') { if (contactType === 'person') {
const order = ['general', 'person', 'address', 'notes', 'meta', 'company']; const order = ['general', 'person', 'address', 'notes', 'meta', 'custom', 'company'];
return [...SORT_FIELDS].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general')); return [...defs].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
} }
const order = ['general', 'company', 'person', 'address', 'notes', 'meta']; const order = ['general', 'company', 'person', 'address', 'notes', 'meta', 'custom'];
return [...SORT_FIELDS].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general')); return [...defs].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
} }
// ─── Component ──────────────────────────────────────────────────────────────── // ─── Component ────────────────────────────────────────────────────────────────
@@ -161,6 +168,31 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const [panelPos, setPanelPos] = useState({ top: 0, left: 0 }); 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(() => { useEffect(() => {
if (!open) return; if (!open) return;
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
@@ -175,12 +207,14 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
const handleToggle = () => { const handleToggle = () => {
if (!open && btnRef.current) { if (!open && btnRef.current) {
const rect = btnRef.current.getBoundingClientRect(); 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); setOpen(!open);
}; };
const orderedFields = useMemo(() => getOrderedFields(contactType), [contactType]); const orderedFields = useMemo(() => getOrderedFields(contactType, allSortFields), [contactType, allSortFields]);
const activeCount = sortState.conditions.length; const activeCount = sortState.conditions.length;
const addCondition = () => { const addCondition = () => {
@@ -239,6 +273,7 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
address: 'Adresse', address: 'Adresse',
notes: 'Notizen', notes: 'Notizen',
meta: 'Metadaten', meta: 'Metadaten',
custom: 'Benutzerdefinierte Felder',
}; };
return ( return (
@@ -273,7 +308,7 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
style={{ style={{
top: panelPos.top, top: panelPos.top,
left: panelPos.left, left: panelPos.left,
width: '420px', width: 'min(420px, 90vw)',
maxHeight: '70vh', maxHeight: '70vh',
overflowY: 'auto', overflowY: 'auto',
zIndex: 3000, zIndex: 3000,
@@ -313,7 +348,7 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
{activeCount > 0 && ( {activeCount > 0 && (
<div className="flex flex-wrap gap-1.5 px-4 py-2 border-b border-secondary-100"> <div className="flex flex-wrap gap-1.5 px-4 py-2 border-b border-secondary-100">
{sortState.conditions.map((cond, idx) => { {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 ( return (
<span <span
key={cond.id} key={cond.id}
@@ -334,7 +369,7 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
{/* Sort rows */} {/* Sort rows */}
<div className="px-4 py-3 space-y-2"> <div className="px-4 py-3 space-y-2">
{sortState.conditions.map((cond, idx) => { {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 ( return (
<div key={cond.id} className="flex items-center gap-1.5"> <div key={cond.id} className="flex items-center gap-1.5">
{/* Priority number + reorder */} {/* Priority number + reorder */}
+67 -3
View File
@@ -29,8 +29,12 @@ import { useSavedFilters as useSavedFiltersApi, useCreateSavedFilter, useDeleteS
import { import {
useUnifiedContacts, useUnifiedContacts,
useUnifiedContact, useUnifiedContact,
useDeleteUnifiedContact,
useUpdateUnifiedContact,
type UnifiedContact, type UnifiedContact,
} from '@/api/hooks'; } from '@/api/hooks';
import { useContactFolders } from '@/api/contacts';
import { useCustomFieldDefinitions } from '@/api/customFieldDefinitions';
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
@@ -56,8 +60,31 @@ export function ContactsListPage() {
const [multiSelectFolders, setMultiSelectFolders] = useState<string[]>([]); const [multiSelectFolders, setMultiSelectFolders] = useState<string[]>([]);
const [activeViewId, setActiveViewId] = useState<string | null>(null); const [activeViewId, setActiveViewId] = useState<string | null>(null);
const [saveViewDialogOpen, setSaveViewDialogOpen] = useState(false); const [saveViewDialogOpen, setSaveViewDialogOpen] = useState(false);
const [selectedContactIds, setSelectedContactIds] = useState<Set<string>>(new Set());
const openWindow = useWindowStore((s) => s.openWindow); 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) // Saved views from API (durable, per-user)
const { data: apiSavedViews } = useSavedViews('contacts'); const { data: apiSavedViews } = useSavedViews('contacts');
const createViewMut = useCreateSavedView(); const createViewMut = useCreateSavedView();
@@ -144,14 +171,14 @@ export function ContactsListPage() {
}); });
} }
// Apply FilterPanel conditions // Apply FilterPanel conditions
result = applyFilters(result, filterState); result = applyFilters(result, filterState, customDefsForFunctions);
// Apply SortPanel sorting // Apply SortPanel sorting
result = applySorting(result, sortState); result = applySorting(result, sortState, customDefsForFunctions);
return result; return result;
}, [contacts, tagFilter, filterState, sortState, multiSelectFolders]); }, [contacts, tagFilter, filterState, sortState, multiSelectFolders]);
// Apply GroupPanel grouping // Apply GroupPanel grouping
const groupedContacts = useMemo(() => applyGrouping(filteredContacts, groupState), [filteredContacts, groupState]); const groupedContacts = useMemo(() => applyGrouping(filteredContacts, groupState, customDefsForFunctions), [filteredContacts, groupState, customDefsForFunctions]);
// Handle folder selection // Handle folder selection
const handleSelectFilter = useCallback((filter: ContactFilter) => { const handleSelectFilter = useCallback((filter: ContactFilter) => {
@@ -221,6 +248,33 @@ export function ContactsListPage() {
setActiveView('list'); 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 // Save current view configuration as a custom view — opens dialog
const handleSaveView = useCallback(() => { const handleSaveView = useCallback(() => {
setSaveViewDialogOpen(true); setSaveViewDialogOpen(true);
@@ -585,6 +639,11 @@ export function ContactsListPage() {
onSortStateChange={setSortState} onSortStateChange={setSortState}
groupState={groupState} groupState={groupState}
groupedContacts={groupedContacts} groupedContacts={groupedContacts}
selectedContactIds={selectedContactIds}
onSelectionChange={setSelectedContactIds}
onBulkDelete={handleBulkDelete}
onBulkAssignFolder={handleBulkAssignFolder}
onBulkAddTags={handleBulkAddTags}
/> />
</div> </div>
</ResizablePanel> </ResizablePanel>
@@ -662,6 +721,11 @@ export function ContactsListPage() {
onSortStateChange={setSortState} onSortStateChange={setSortState}
groupState={groupState} groupState={groupState}
groupedContacts={groupedContacts} groupedContacts={groupedContacts}
selectedContactIds={selectedContactIds}
onSelectionChange={setSelectedContactIds}
onBulkDelete={handleBulkDelete}
onBulkAssignFolder={handleBulkAssignFolder}
onBulkAddTags={handleBulkAddTags}
/> />
</div> </div>
)} )}