feat: SmartSuite-style FilterPanel with multi-condition AND/OR, all contact fields, context-sensitive ordering
This commit is contained in:
@@ -0,0 +1,547 @@
|
|||||||
|
/**
|
||||||
|
* SmartSuite-style Filter Panel for contacts.
|
||||||
|
* Multi-condition AND/OR filtering with all UnifiedContact fields.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { Filter, Plus, X, ChevronDown } from 'lucide-react';
|
||||||
|
import type { UnifiedContact } from '@/api/unifiedContacts';
|
||||||
|
|
||||||
|
// ─── Field definitions ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type FieldType = 'text' | 'number' | 'select' | 'date';
|
||||||
|
|
||||||
|
interface FilterFieldDef {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: FieldType;
|
||||||
|
options?: { value: string; label: string }[]; group?: 'general' | 'company' | 'person' | 'address' | 'finance' | 'notes' | 'meta';
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_DEFS: FilterFieldDef[] = [
|
||||||
|
// General
|
||||||
|
{ key: 'displayname', label: 'Anzeigename', type: 'text', group: 'general' },
|
||||||
|
{ key: 'type', label: 'Typ', type: 'select', group: 'general', options: [
|
||||||
|
{ value: 'company', label: 'Firma' },
|
||||||
|
{ value: 'person', label: 'Person' },
|
||||||
|
]},
|
||||||
|
{ key: 'email_1', label: 'E-Mail 1', type: 'text', group: 'general' },
|
||||||
|
{ key: 'email_2', label: 'E-Mail 2', type: 'text', group: 'general' },
|
||||||
|
{ key: 'phone_1', label: 'Telefon 1', type: 'text', group: 'general' },
|
||||||
|
{ key: 'phone_2', label: 'Telefon 2', type: 'text', group: 'general' },
|
||||||
|
{ key: 'website', label: 'Website', type: 'text', group: 'general' },
|
||||||
|
{ key: 'tags', label: 'Tags', type: 'text', group: 'general' },
|
||||||
|
|
||||||
|
// Company-focused
|
||||||
|
{ key: 'name', label: 'Firmenname', type: 'text', group: 'company' },
|
||||||
|
{ key: 'code', label: 'Kunden-Nr.', type: 'text', group: 'company' },
|
||||||
|
{ key: 'accounting_code', label: 'Buchhaltungs-Code', type: 'text', group: 'company' },
|
||||||
|
{ key: 'vendor_accounting_code', label: 'Lieferanten-Code', type: 'text', group: 'company' },
|
||||||
|
{ key: 'vat_code', label: 'USt-IdNr.', type: 'text', group: 'company' },
|
||||||
|
{ key: 'fiscal_code', label: 'Steuernummer', type: 'text', group: 'company' },
|
||||||
|
{ key: 'commerce_code', label: 'Handelsregister-Nr.', type: 'text', group: 'company' },
|
||||||
|
{ key: 'bic', label: 'BIC', type: 'text', group: 'company' },
|
||||||
|
{ key: 'bank_account', label: 'Bankkonto', type: 'text', group: 'company' },
|
||||||
|
{ key: 'purchase_number', label: 'Bestellnummer', type: 'text', group: 'company' },
|
||||||
|
|
||||||
|
// Person-focused
|
||||||
|
{ key: 'firstname', label: 'Vorname', type: 'text', group: 'person' },
|
||||||
|
{ key: 'surname', label: 'Nachname', type: 'text', group: 'person' },
|
||||||
|
{ key: 'suffix', label: 'Suffix', type: 'text', group: 'person' },
|
||||||
|
{ key: 'gender', label: 'Geschlecht', type: 'select', group: 'person', options: [
|
||||||
|
{ value: 'm', label: 'Männlich' },
|
||||||
|
{ value: 'f', label: 'Weiblich' },
|
||||||
|
{ value: 'd', label: 'Divers' },
|
||||||
|
]},
|
||||||
|
{ key: 'ext_name_line', label: 'Zusatzname', type: 'text', group: 'person' },
|
||||||
|
|
||||||
|
// Address (mailing)
|
||||||
|
{ key: 'mailing_street', label: 'Straße (Post)', type: 'text', group: 'address' },
|
||||||
|
{ key: 'mailing_number', label: 'Hausnr. (Post)', type: 'text', group: 'address' },
|
||||||
|
{ key: 'mailing_postalcode', label: 'PLZ (Post)', type: 'text', group: 'address' },
|
||||||
|
{ key: 'mailing_city', label: 'Stadt (Post)', type: 'text', group: 'address' },
|
||||||
|
{ key: 'mailing_state', label: 'Bundesland (Post)', type: 'text', group: 'address' },
|
||||||
|
{ key: 'mailing_country', label: 'Land (Post)', type: 'text', group: 'address' },
|
||||||
|
// Visit
|
||||||
|
{ key: 'visit_street', label: 'Straße (Besuch)', type: 'text', group: 'address' },
|
||||||
|
{ key: 'visit_city', label: 'Stadt (Besuch)', type: 'text', group: 'address' },
|
||||||
|
{ key: 'visit_postalcode', label: 'PLZ (Besuch)', type: 'text', group: 'address' },
|
||||||
|
// Invoice
|
||||||
|
{ key: 'invoice_city', label: 'Stadt (Rechnung)', type: 'text', group: 'address' },
|
||||||
|
{ key: 'invoice_postalcode', label: 'PLZ (Rechnung)', type: 'text', group: 'address' },
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
{ key: 'projectnote', label: 'Projektnotiz', type: 'text', group: 'notes' },
|
||||||
|
{ key: 'projectnote_title', label: 'Notiztitel', type: 'text', group: 'notes' },
|
||||||
|
{ key: 'contact_warning', label: 'Warnung', type: 'text', group: 'notes' },
|
||||||
|
|
||||||
|
// Meta
|
||||||
|
{ key: 'folder_id', label: 'Ordner-ID', type: 'text', group: 'meta' },
|
||||||
|
{ key: 'created_at', label: 'Erstellt am', type: 'date', group: 'meta' },
|
||||||
|
{ key: 'updated_at', label: 'Geändert am', type: 'date', group: 'meta' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Operators ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface OperatorDef {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
needsValue: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEXT_OPERATORS: OperatorDef[] = [
|
||||||
|
{ value: 'contains', label: 'enthält', needsValue: true },
|
||||||
|
{ value: 'equals', label: 'ist gleich', needsValue: true },
|
||||||
|
{ value: 'startsWith', label: 'beginnt mit', needsValue: true },
|
||||||
|
{ value: 'endsWith', label: 'endet mit', needsValue: true },
|
||||||
|
{ value: 'isEmpty', label: 'ist leer', needsValue: false },
|
||||||
|
{ value: 'isNotEmpty', label: 'ist nicht leer', needsValue: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SELECT_OPERATORS: OperatorDef[] = [
|
||||||
|
{ value: 'equals', label: 'ist', needsValue: true },
|
||||||
|
{ value: 'notEquals', label: 'ist nicht', needsValue: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DATE_OPERATORS: OperatorDef[] = [
|
||||||
|
{ value: 'before', label: 'vor', needsValue: true },
|
||||||
|
{ value: 'after', label: 'nach', needsValue: true },
|
||||||
|
{ value: 'on', label: 'an', needsValue: true },
|
||||||
|
{ value: 'isEmpty', label: 'ist leer', needsValue: false },
|
||||||
|
{ value: 'isNotEmpty', label: 'ist nicht leer', needsValue: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getOperators(fieldType: FieldType): OperatorDef[] {
|
||||||
|
if (fieldType === 'select') return SELECT_OPERATORS;
|
||||||
|
if (fieldType === 'date') return DATE_OPERATORS;
|
||||||
|
return TEXT_OPERATORS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface FilterCondition {
|
||||||
|
id: string;
|
||||||
|
field: string;
|
||||||
|
operator: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterState {
|
||||||
|
logic: 'AND' | 'OR';
|
||||||
|
conditions: FilterCondition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emptyFilterState: FilterState = {
|
||||||
|
logic: 'AND',
|
||||||
|
conditions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Filter logic ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getFieldValue(contact: UnifiedContact, field: string): any {
|
||||||
|
return (contact as any)[field];
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesCondition(contact: UnifiedContact, cond: FilterCondition): boolean {
|
||||||
|
const def = FIELD_DEFS.find((f) => f.key === cond.field);
|
||||||
|
if (!def) return true;
|
||||||
|
const val = getFieldValue(contact, cond.field);
|
||||||
|
const op = cond.operator;
|
||||||
|
const searchVal = cond.value.toLowerCase().trim();
|
||||||
|
|
||||||
|
switch (op) {
|
||||||
|
case 'contains':
|
||||||
|
return val != null && String(val).toLowerCase().includes(searchVal);
|
||||||
|
case 'equals':
|
||||||
|
return val != null && String(val).toLowerCase() === searchVal;
|
||||||
|
case 'notEquals':
|
||||||
|
return val == null || String(val).toLowerCase() !== searchVal;
|
||||||
|
case 'startsWith':
|
||||||
|
return val != null && String(val).toLowerCase().startsWith(searchVal);
|
||||||
|
case 'endsWith':
|
||||||
|
return val != null && String(val).toLowerCase().endsWith(searchVal);
|
||||||
|
case 'isEmpty':
|
||||||
|
return val == null || val === '' || val === undefined;
|
||||||
|
case 'isNotEmpty':
|
||||||
|
return val != null && val !== '' && val !== undefined;
|
||||||
|
case 'before':
|
||||||
|
return val != null && new Date(val) < new Date(cond.value);
|
||||||
|
case 'after':
|
||||||
|
return val != null && new Date(val) > new Date(cond.value);
|
||||||
|
case 'on':
|
||||||
|
return val != null && new Date(val).toDateString() === new Date(cond.value).toDateString();
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyFilters(contacts: UnifiedContact[], filters: FilterState): UnifiedContact[] {
|
||||||
|
if (!filters.conditions.length) return contacts;
|
||||||
|
if (filters.logic === 'AND') {
|
||||||
|
return contacts.filter((c) => filters.conditions.every((cond) => matchesCondition(c, cond)));
|
||||||
|
} else {
|
||||||
|
return contacts.filter((c) => filters.conditions.some((cond) => matchesCondition(c, cond)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Context-sensitive field ordering ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function getOrderedFields(contactType?: 'company' | 'person' | undefined): FilterFieldDef[] {
|
||||||
|
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 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 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 ai = order.indexOf(a.group || 'general');
|
||||||
|
const bi = order.indexOf(b.group || 'general');
|
||||||
|
return ai - bi;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface FilterPanelProps {
|
||||||
|
filters: FilterState;
|
||||||
|
onFiltersChange: (filters: FilterState) => void;
|
||||||
|
contactType?: 'company' | 'person' | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let condIdCounter = 0;
|
||||||
|
function newConditionId() {
|
||||||
|
return `cond-${Date.now()}-${++condIdCounter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterPanel({ filters, onFiltersChange, contactType }: FilterPanelProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const btnRef = useRef<HTMLDivElement>(null);
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [panelPos, setPanelPos] = useState({ top: 0, left: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
const target = e.target as Node;
|
||||||
|
if (btnRef.current?.contains(target) || panelRef.current?.contains(target)) return;
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
if (!open && btnRef.current) {
|
||||||
|
const rect = btnRef.current.getBoundingClientRect();
|
||||||
|
setPanelPos({ top: rect.bottom + 4, left: rect.left });
|
||||||
|
}
|
||||||
|
setOpen(!open);
|
||||||
|
};
|
||||||
|
|
||||||
|
const orderedFields = useMemo(() => getOrderedFields(contactType), [contactType]);
|
||||||
|
const activeCount = filters.conditions.length;
|
||||||
|
|
||||||
|
const addCondition = () => {
|
||||||
|
onFiltersChange({
|
||||||
|
...filters,
|
||||||
|
conditions: [...filters.conditions, {
|
||||||
|
id: newConditionId(),
|
||||||
|
field: orderedFields[0]?.key || 'displayname',
|
||||||
|
operator: 'contains',
|
||||||
|
value: '',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCondition = (id: string) => {
|
||||||
|
onFiltersChange({
|
||||||
|
...filters,
|
||||||
|
conditions: filters.conditions.filter((c) => c.id !== id),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCondition = (id: string, updates: Partial<FilterCondition>) => {
|
||||||
|
onFiltersChange({
|
||||||
|
...filters,
|
||||||
|
conditions: filters.conditions.map((c) =>
|
||||||
|
c.id === id ? { ...c, ...updates } : c
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
onFiltersChange({ ...emptyFilterState });
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleLogic = () => {
|
||||||
|
onFiltersChange({ ...filters, logic: filters.logic === 'AND' ? 'OR' : 'AND' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group fields by group for the dropdown
|
||||||
|
const groupedFields = useMemo(() => {
|
||||||
|
const groups: Record<string, FilterFieldDef[]> = {};
|
||||||
|
for (const f of orderedFields) {
|
||||||
|
const g = f.group || 'general';
|
||||||
|
if (!groups[g]) groups[g] = [];
|
||||||
|
groups[g].push(f);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}, [orderedFields]);
|
||||||
|
|
||||||
|
const groupLabels: Record<string, string> = {
|
||||||
|
general: 'Allgemein',
|
||||||
|
company: 'Firma',
|
||||||
|
person: 'Person',
|
||||||
|
address: 'Adresse',
|
||||||
|
finance: 'Finanzen',
|
||||||
|
notes: 'Notizen',
|
||||||
|
meta: 'Metadaten',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div ref={btnRef} className="relative flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={handleToggle}
|
||||||
|
title="Filter"
|
||||||
|
aria-label="Filter"
|
||||||
|
className={`
|
||||||
|
inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
|
||||||
|
transition-colors duration-100 cursor-pointer relative
|
||||||
|
${open || activeCount > 0 ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Filter className="w-3.5 h-3.5" strokeWidth={2} />
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<span className="inline-flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-bold rounded-full bg-primary-600 text-white">
|
||||||
|
{activeCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<svg className={`w-3 h-3 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="fixed bg-white border border-secondary-200 rounded-lg shadow-lg"
|
||||||
|
style={{
|
||||||
|
top: panelPos.top,
|
||||||
|
left: panelPos.left,
|
||||||
|
width: '480px',
|
||||||
|
maxHeight: '70vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
zIndex: 3000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary-100">
|
||||||
|
<span className="text-sm font-semibold text-secondary-800">Filter</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={clearAll}
|
||||||
|
className="text-xs text-secondary-500 hover:text-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
Alle löschen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="p-1 rounded hover:bg-secondary-100 text-secondary-400 hover:text-secondary-600"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logic toggle */}
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-secondary-100">
|
||||||
|
<span className="text-xs text-secondary-500">Bedingungen verknüpfen:</span>
|
||||||
|
<button
|
||||||
|
onClick={toggleLogic}
|
||||||
|
className={`
|
||||||
|
px-2 py-0.5 text-xs font-medium rounded transition-colors
|
||||||
|
${filters.logic === 'AND'
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
UND
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleLogic}
|
||||||
|
className={`
|
||||||
|
px-2 py-0.5 text-xs font-medium rounded transition-colors
|
||||||
|
${filters.logic === 'OR'
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
ODER
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active filter badges */}
|
||||||
|
{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 op = getOperators(def?.type || 'text').find((o) => o.value === cond.operator);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={cond.id}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] rounded-full bg-primary-50 text-primary-700 border border-primary-200"
|
||||||
|
>
|
||||||
|
{def?.label} {op?.label} {cond.value && `"${cond.value}"`}
|
||||||
|
<button
|
||||||
|
onClick={() => removeCondition(cond.id)}
|
||||||
|
className="hover:text-red-600"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filter rows */}
|
||||||
|
<div className="px-4 py-3 space-y-2">
|
||||||
|
{filters.conditions.length === 0 && (
|
||||||
|
<div className="text-center py-6 text-xs text-secondary-400">
|
||||||
|
Keine Filter aktiv. Klicke unten um eine Bedingung hinzuzufügen.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filters.conditions.map((cond, idx) => {
|
||||||
|
const def = FIELD_DEFS.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">
|
||||||
|
{/* Logic prefix */}
|
||||||
|
{idx > 0 && (
|
||||||
|
<span className="text-[10px] font-bold text-primary-600 w-8 text-center">
|
||||||
|
{filters.logic}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{idx === 0 && <div className="w-8" />}
|
||||||
|
|
||||||
|
{/* Field dropdown */}
|
||||||
|
<select
|
||||||
|
value={cond.field}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newDef = FIELD_DEFS.find((f) => f.key === e.target.value);
|
||||||
|
const newOps = getOperators(newDef?.type || 'text');
|
||||||
|
updateCondition(cond.id, {
|
||||||
|
field: e.target.value,
|
||||||
|
operator: newOps[0].value,
|
||||||
|
value: '',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="flex-1 min-w-0 px-2 py-1.5 text-xs border border-secondary-200 rounded bg-white cursor-pointer focus:outline-none focus:border-primary-400"
|
||||||
|
>
|
||||||
|
{Object.entries(groupedFields).map(([group, fields]) => (
|
||||||
|
<optgroup key={group} label={groupLabels[group] || group}>
|
||||||
|
{fields.map((f) => (
|
||||||
|
<option key={f.key} value={f.key}>{f.label}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Operator dropdown */}
|
||||||
|
<select
|
||||||
|
value={cond.operator}
|
||||||
|
onChange={(e) => updateCondition(cond.id, { operator: e.target.value })}
|
||||||
|
className="px-2 py-1.5 text-xs border border-secondary-200 rounded bg-white cursor-pointer focus:outline-none focus:border-primary-400"
|
||||||
|
>
|
||||||
|
{operators.map((op) => (
|
||||||
|
<option key={op.value} value={op.value}>{op.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Value input */}
|
||||||
|
{currentOp?.needsValue ? (
|
||||||
|
def?.type === 'select' ? (
|
||||||
|
<select
|
||||||
|
value={cond.value}
|
||||||
|
onChange={(e) => updateCondition(cond.id, { value: e.target.value })}
|
||||||
|
className="px-2 py-1.5 text-xs border border-secondary-200 rounded bg-white cursor-pointer focus:outline-none focus:border-primary-400"
|
||||||
|
>
|
||||||
|
<option value="">— wählen —</option>
|
||||||
|
{def.options?.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : def?.type === 'date' ? (
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={cond.value}
|
||||||
|
onChange={(e) => updateCondition(cond.id, { value: e.target.value })}
|
||||||
|
className="px-2 py-1.5 text-xs border border-secondary-200 rounded focus:outline-none focus:border-primary-400"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={cond.value}
|
||||||
|
onChange={(e) => updateCondition(cond.id, { value: e.target.value })}
|
||||||
|
placeholder="Wert…"
|
||||||
|
className="w-24 px-2 py-1.5 text-xs border border-secondary-200 rounded focus:outline-none focus:border-primary-400"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="w-24" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Remove button */}
|
||||||
|
<button
|
||||||
|
onClick={() => removeCondition(cond.id)}
|
||||||
|
className="p-1 rounded hover:bg-red-50 text-secondary-400 hover:text-red-600 transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-secondary-100">
|
||||||
|
<button
|
||||||
|
onClick={addCondition}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
Bedingung hinzufügen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Fertig
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -228,6 +228,7 @@ export function PluginToolbar() {
|
|||||||
if (item.type === 'search') return <ToolbarSearch key={item.id} item={item} />;
|
if (item.type === 'search') return <ToolbarSearch key={item.id} item={item} />;
|
||||||
if (item.type === 'select') return <ToolbarSelect key={item.id} item={item} />;
|
if (item.type === 'select') return <ToolbarSelect key={item.id} item={item} />;
|
||||||
if (item.type === 'dropdown') return <ToolbarDropdown key={item.id} item={item} />;
|
if (item.type === 'dropdown') return <ToolbarDropdown key={item.id} item={item} />;
|
||||||
|
if (item.type === 'custom' && item.customComponent) return <React.Fragment key={item.id}>{item.customComponent}</React.Fragment>;
|
||||||
return <ToolbarButton key={item.id} item={item} />;
|
return <ToolbarButton key={item.id} item={item} />;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import { SavedFilters } from '@/components/SavedFilters';
|
|||||||
import { SavedFilterBar } from '@/components/common/SavedFilterBar';
|
import { SavedFilterBar } from '@/components/common/SavedFilterBar';
|
||||||
import { TagSelector } from '@/components/tags/TagSelector';
|
import { TagSelector } from '@/components/tags/TagSelector';
|
||||||
import type { Tag } from '@/api/tags';
|
import type { Tag } from '@/api/tags';
|
||||||
import { ArrowDownAZ, ArrowUpZA, Bookmark, ChevronLeft, ExternalLink, Filter, LayoutGrid, List, Plus, Printer, Table2 } from 'lucide-react';
|
import { ArrowDownAZ, ArrowUpZA, Bookmark, ChevronLeft, ExternalLink, LayoutGrid, List, Plus, Printer, Table2 } from 'lucide-react';
|
||||||
|
import { FilterPanel, applyFilters, emptyFilterState, type FilterState } from '@/components/contacts/FilterPanel';
|
||||||
import {
|
import {
|
||||||
useUnifiedContacts,
|
useUnifiedContacts,
|
||||||
useUnifiedContact,
|
useUnifiedContact,
|
||||||
@@ -42,6 +43,7 @@ export function ContactsListPage() {
|
|||||||
const [activeView, setActiveView] = useState<'folders' | 'list' | 'detail'>('folders');
|
const [activeView, setActiveView] = useState<'folders' | 'list' | 'detail'>('folders');
|
||||||
const [savedFiltersOpen, setSavedFiltersOpen] = useState(false);
|
const [savedFiltersOpen, setSavedFiltersOpen] = useState(false);
|
||||||
const [selectedTags, setSelectedTags] = useState<Tag[]>([]);
|
const [selectedTags, setSelectedTags] = useState<Tag[]>([]);
|
||||||
|
const [filterState, setFilterState] = useState<FilterState>(emptyFilterState);
|
||||||
const openWindow = useWindowStore((s) => s.openWindow);
|
const openWindow = useWindowStore((s) => s.openWindow);
|
||||||
|
|
||||||
// Debounce search
|
// Debounce search
|
||||||
@@ -100,14 +102,19 @@ export function ContactsListPage() {
|
|||||||
return Array.from(tagSet).sort();
|
return Array.from(tagSet).sort();
|
||||||
}, [contacts]);
|
}, [contacts]);
|
||||||
|
|
||||||
// Filter by tag client-side if tag filter is active
|
// Filter by tag client-side if tag filter is active, then apply FilterPanel conditions
|
||||||
const filteredContacts = useMemo(() => {
|
const filteredContacts = useMemo(() => {
|
||||||
if (!tagFilter) return contacts;
|
let result = contacts;
|
||||||
return contacts.filter((c) => {
|
if (tagFilter) {
|
||||||
if (!c.tags) return false;
|
result = result.filter((c) => {
|
||||||
return c.tags.split(',').map((t) => t.trim()).includes(tagFilter);
|
if (!c.tags) return false;
|
||||||
});
|
return c.tags.split(',').map((t) => t.trim()).includes(tagFilter);
|
||||||
}, [contacts, tagFilter]);
|
});
|
||||||
|
}
|
||||||
|
// Apply FilterPanel conditions
|
||||||
|
result = applyFilters(result, filterState);
|
||||||
|
return result;
|
||||||
|
}, [contacts, tagFilter, filterState]);
|
||||||
|
|
||||||
// Handle folder selection
|
// Handle folder selection
|
||||||
const handleSelectFilter = useCallback((filter: ContactFilter) => {
|
const handleSelectFilter = useCallback((filter: ContactFilter) => {
|
||||||
@@ -209,32 +216,38 @@ export function ContactsListPage() {
|
|||||||
onSearch: handleSearch,
|
onSearch: handleSearch,
|
||||||
onClick: () => {},
|
onClick: () => {},
|
||||||
},
|
},
|
||||||
// Unified filter dropdown — filter + sort in one panel
|
// FilterPanel — SmartSuite-style multi-condition filter
|
||||||
{
|
{
|
||||||
id: 'filter-sort',
|
id: 'filter-panel',
|
||||||
plugin: 'contacts',
|
plugin: 'contacts',
|
||||||
label: t('common.filter', 'Filter'),
|
label: 'Filter',
|
||||||
type: 'dropdown' as const,
|
type: 'custom' as const,
|
||||||
group: 'filter',
|
group: 'filter',
|
||||||
icon: <Filter className="w-3.5 h-3.5" strokeWidth={2} />,
|
customComponent: (
|
||||||
iconOnly: true,
|
<FilterPanel
|
||||||
menuWidth: '240px',
|
filters={filterState}
|
||||||
|
onFiltersChange={setFilterState}
|
||||||
|
contactType={contactType}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
onClick: () => {},
|
||||||
|
},
|
||||||
|
// Sort dropdown
|
||||||
|
{
|
||||||
|
id: 'sort-by',
|
||||||
|
plugin: 'contacts',
|
||||||
|
label: t('common.sort', 'Sortieren'),
|
||||||
|
type: 'dropdown' as const,
|
||||||
|
group: 'sort',
|
||||||
|
icon: <ArrowDownAZ className="w-3.5 h-3.5" strokeWidth={2} />,
|
||||||
|
menuWidth: '200px',
|
||||||
menuOptions: [
|
menuOptions: [
|
||||||
// Section: Filter
|
|
||||||
{ value: 'all', label: t('common.all'), section: t('common.filter', 'Filter'), active: selectedFilter === 'all', onClick: () => handleSelectFilter('all') },
|
|
||||||
{ value: 'company', label: t('contacts.companies'), active: selectedFilter === 'company', onClick: () => handleSelectFilter('company') },
|
|
||||||
{ value: 'person', label: t('contacts.persons'), active: selectedFilter === 'person', onClick: () => handleSelectFilter('person') },
|
|
||||||
// Separator
|
|
||||||
{ value: 'sep-sort', label: '', separator: true, onClick: () => {} },
|
|
||||||
// Section: Sort by
|
|
||||||
...sortOptions.map((opt) => ({
|
...sortOptions.map((opt) => ({
|
||||||
value: `sort-${opt.value}`, label: opt.label, section: t('common.sortBy', 'Sortieren nach'),
|
value: `sort-${opt.value}`, label: opt.label, section: t('common.sortBy', 'Sortieren nach'),
|
||||||
active: sortBy === opt.value, onClick: () => { setSortBy(opt.value); setPage(1); },
|
active: sortBy === opt.value, onClick: () => { setSortBy(opt.value); setPage(1); },
|
||||||
})),
|
})),
|
||||||
// Separator
|
|
||||||
{ value: 'sep-order', label: '', separator: true, onClick: () => {} },
|
{ value: 'sep-order', label: '', separator: true, onClick: () => {} },
|
||||||
// Section: Sort order
|
{ value: 'sort-asc', label: t('common.sortAsc', 'Aufsteigend'), section: t('common.sortOrder', 'Reihenfolge'),
|
||||||
{ value: 'sort-asc', label: t('common.sortAsc', 'Aufsteigend'), section: t('common.sortOrder', 'Sortierreihenfolge'),
|
|
||||||
icon: <ArrowDownAZ className="w-3.5 h-3.5" strokeWidth={2} />, active: sortOrder === 'asc', onClick: () => handleSortChange(sortBy, 'asc') },
|
icon: <ArrowDownAZ className="w-3.5 h-3.5" strokeWidth={2} />, active: sortOrder === 'asc', onClick: () => handleSortChange(sortBy, 'asc') },
|
||||||
{ value: 'sort-desc', label: t('common.sortDesc', 'Absteigend'),
|
{ value: 'sort-desc', label: t('common.sortDesc', 'Absteigend'),
|
||||||
icon: <ArrowUpZA className="w-3.5 h-3.5" strokeWidth={2} />, active: sortOrder === 'desc', onClick: () => handleSortChange(sortBy, 'desc') },
|
icon: <ArrowUpZA className="w-3.5 h-3.5" strokeWidth={2} />, active: sortOrder === 'desc', onClick: () => handleSortChange(sortBy, 'desc') },
|
||||||
@@ -300,7 +313,7 @@ export function ContactsListPage() {
|
|||||||
];
|
];
|
||||||
registerItems('contacts', items);
|
registerItems('contacts', items);
|
||||||
return () => unregisterPlugin('contacts');
|
return () => unregisterPlugin('contacts');
|
||||||
}, [handleSearch, handleCreate, handleSelectFilter, handleSortChange, t, registerItems, unregisterPlugin, sortBy, sortOrder, selectedFilter, viewMode, sortOptions, viewModeOptions]);
|
}, [handleSearch, handleCreate, handleSelectFilter, handleSortChange, t, registerItems, unregisterPlugin, sortBy, sortOrder, selectedFilter, viewMode, sortOptions, viewModeOptions, filterState, contactType]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full" data-testid="contacts-list-page">
|
<div className="flex flex-col h-full" data-testid="contacts-list-page">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface ToolbarItem {
|
|||||||
group?: string;
|
group?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
type?: 'button' | 'search' | 'select' | 'dropdown';
|
type?: 'button' | 'search' | 'select' | 'dropdown' | 'custom';
|
||||||
searchPlaceholder?: string;
|
searchPlaceholder?: string;
|
||||||
onSearch?: (query: string) => void;
|
onSearch?: (query: string) => void;
|
||||||
selectOptions?: { value: string; label: string }[];
|
selectOptions?: { value: string; label: string }[];
|
||||||
@@ -18,6 +18,7 @@ export interface ToolbarItem {
|
|||||||
menuOptions?: { value: string; label: string; icon?: React.ReactNode; active?: boolean; onClick: () => void; section?: string; separator?: boolean; disabled?: boolean }[];
|
menuOptions?: { value: string; label: string; icon?: React.ReactNode; active?: boolean; onClick: () => void; section?: string; separator?: boolean; disabled?: boolean }[];
|
||||||
menuWidth?: string;
|
menuWidth?: string;
|
||||||
iconOnly?: boolean;
|
iconOnly?: boolean;
|
||||||
|
customComponent?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PluginToolbarState {
|
interface PluginToolbarState {
|
||||||
|
|||||||
Reference in New Issue
Block a user