abbe7a18fc
- P0: hooks.py 3-tuple fix, trigger_dispatcher Contract, contacts/plugin unregister_actions_by_owner - P0: 5 test files — check_permission mocks removed, hardcoded DB credential → env var - P1: attachment_service DmsFile via Contract helper, restore_registry/history_hooks dedup - P1: mail/plugin restore unregister, mcp_client datetime.now(UTC), saved_views/filters patterns - P1: ProtectedRoute fail-closed, 13 test assertion fixes (bcrypt, DB-URLs, SECRET_KEYs) - P2: deprecated notifications → post_system_message (3 files), forgejo Base, report_generator lazy import - P2: webhooks permissions, deps.py/roles.py plugin perms removed, import_export default - P2: address/tags/entity_links patterns removed, worker.py Contract-Umgehungen fixed - P2: 28 frontend TODOs (hardcoded constants, deprecated notification API) - P3: dead code, duplicates, deprecated imports, private attr, __import__ inline - P3: 8 frontend TODOs (LucideIcons, inline styles, XSS, i18n) - ruff: 838 → 0 (612 auto-fix + 246 manual + 27 F821 regression fix) - F821: 30 → 0 (AutomationDefinition, DmsFile, user_id, Path, Any, String) - Contract-Umgehungen: 2 neue gefunden (worker.py:169, worker.py:280) und gefixt
452 lines
18 KiB
TypeScript
452 lines
18 KiB
TypeScript
// TODO: P2-F12 — Replace hardcoded GROUP_FIELDS with shared constant
|
|
/**
|
|
* SmartSuite-style Group Panel for contacts.
|
|
* Multi-field grouping with priority ordering.
|
|
*/
|
|
|
|
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 ────────────────────────────────────────────────────────
|
|
|
|
type FieldType = 'text' | 'number' | 'select' | 'date';
|
|
|
|
interface GroupFieldDef {
|
|
key: string;
|
|
label: string;
|
|
type: FieldType;
|
|
group?: 'general' | 'company' | 'person' | 'address' | 'notes' | 'meta' | 'custom';
|
|
}
|
|
|
|
let GROUP_FIELDS: GroupFieldDef[] = [
|
|
// General
|
|
{ key: 'type', label: 'Typ', type: 'select', group: 'general' },
|
|
{ key: 'displayname', label: 'Anzeigename', type: 'text', group: 'general' },
|
|
{ key: 'email_1', label: 'E-Mail 1', type: 'text', group: 'general' },
|
|
{ key: 'phone_1', label: 'Telefon 1', type: 'text', group: 'general' },
|
|
{ key: 'website', label: 'Website', type: 'text', group: 'general' },
|
|
{ key: 'tags', label: 'Tags', type: 'text', group: 'general' },
|
|
|
|
// Company
|
|
{ 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: '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' },
|
|
|
|
// Person
|
|
{ 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' },
|
|
{ key: 'ext_name_line', label: 'Zusatzname', type: 'text', group: 'person' },
|
|
|
|
// Address
|
|
{ key: 'mailing_street', label: 'Straße (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' },
|
|
{ key: 'visit_city', label: 'Stadt (Besuch)', type: 'text', group: 'address' },
|
|
{ key: 'visit_postalcode', label: 'PLZ (Besuch)', type: 'text', group: 'address' },
|
|
{ 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' },
|
|
];
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
export interface GroupCondition {
|
|
id: string;
|
|
field: string;
|
|
}
|
|
|
|
export interface GroupState {
|
|
conditions: GroupCondition[];
|
|
}
|
|
|
|
export const emptyGroupState: GroupState = {
|
|
conditions: [],
|
|
};
|
|
|
|
// ─── 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];
|
|
}
|
|
|
|
export interface GroupedContacts {
|
|
key: string;
|
|
label: string;
|
|
contacts: UnifiedContact[];
|
|
}
|
|
|
|
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 }];
|
|
}
|
|
|
|
// Multi-level grouping
|
|
let groups: GroupedContacts[] = [{ key: 'all', label: 'Alle', contacts: [...contacts] }];
|
|
|
|
for (const cond of groupState.conditions) {
|
|
const def = defs.find((f) => f.key === cond.field);
|
|
if (!def) continue;
|
|
|
|
const newGroups: GroupedContacts[] = [];
|
|
for (const group of groups) {
|
|
const subGroups = new Map<string, UnifiedContact[]>();
|
|
for (const contact of group.contacts) {
|
|
const val = getFieldValue(contact, cond.field);
|
|
const groupKey = val == null || val === '' ? '(leer)' : String(val);
|
|
if (!subGroups.has(groupKey)) {
|
|
subGroups.set(groupKey, []);
|
|
}
|
|
subGroups.get(groupKey)!.push(contact);
|
|
}
|
|
// Sort group keys alphabetically
|
|
const sortedKeys = Array.from(subGroups.keys()).sort((a, b) => {
|
|
if (a === '(leer)') return 1;
|
|
if (b === '(leer)') return -1;
|
|
return a.localeCompare(b);
|
|
});
|
|
for (const key of sortedKeys) {
|
|
const label = group.key === 'all'
|
|
? `${def.label}: ${key}`
|
|
: `${group.label} > ${key}`;
|
|
newGroups.push({ key: `${group.key}::${key}`, label, contacts: subGroups.get(key)! });
|
|
}
|
|
}
|
|
groups = newGroups;
|
|
}
|
|
|
|
return groups;
|
|
}
|
|
|
|
// ─── Context-sensitive field ordering ──────────────────────────────────────────
|
|
|
|
function getOrderedFields(contactType?: 'company' | 'person' | undefined, allDefs?: GroupFieldDef[]): GroupFieldDef[] {
|
|
const defs = allDefs || GROUP_FIELDS;
|
|
if (contactType === 'company') {
|
|
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', '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', 'custom'];
|
|
return [...defs].sort((a, b) => order.indexOf(a.group || 'general') - order.indexOf(b.group || 'general'));
|
|
}
|
|
|
|
// ─── Component ────────────────────────────────────────────────────────────────
|
|
|
|
interface GroupPanelProps {
|
|
groupState: GroupState;
|
|
onGroupChange: (groupState: GroupState) => void;
|
|
contactType?: 'company' | 'person' | undefined;
|
|
}
|
|
|
|
let groupIdCounter = 0;
|
|
function newGroupId() {
|
|
return `grp-${Date.now()}-${++groupIdCounter}`;
|
|
}
|
|
|
|
export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPanelProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const btnRef = useRef<HTMLDivElement>(null);
|
|
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 || 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) => {
|
|
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();
|
|
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, allGroupFields), [contactType, allGroupFields]);
|
|
const activeCount = groupState.conditions.length;
|
|
|
|
const addCondition = () => {
|
|
onGroupChange({
|
|
conditions: [...groupState.conditions, {
|
|
id: newGroupId(),
|
|
field: orderedFields[0]?.key || 'type',
|
|
}],
|
|
});
|
|
};
|
|
|
|
const removeCondition = (id: string) => {
|
|
onGroupChange({
|
|
conditions: groupState.conditions.filter((c) => c.id !== id),
|
|
});
|
|
};
|
|
|
|
const updateCondition = (id: string, updates: Partial<GroupCondition>) => {
|
|
onGroupChange({
|
|
conditions: groupState.conditions.map((c) =>
|
|
c.id === id ? { ...c, ...updates } : c
|
|
),
|
|
});
|
|
};
|
|
|
|
const clearAll = () => {
|
|
onGroupChange({ ...emptyGroupState });
|
|
};
|
|
|
|
const moveCondition = (id: string, direction: 'up' | 'down') => {
|
|
const conds = [...groupState.conditions];
|
|
const idx = conds.findIndex((c) => c.id === id);
|
|
if (idx < 0) return;
|
|
const newIdx = direction === 'up' ? idx - 1 : idx + 1;
|
|
if (newIdx < 0 || newIdx >= conds.length) return;
|
|
[conds[idx], conds[newIdx]] = [conds[newIdx], conds[idx]];
|
|
onGroupChange({ conditions: conds });
|
|
};
|
|
|
|
// Group fields for dropdown
|
|
const groupedFields = useMemo(() => {
|
|
const groups: Record<string, GroupFieldDef[]> = {};
|
|
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',
|
|
notes: 'Notizen',
|
|
meta: 'Metadaten',
|
|
custom: 'Benutzerdefinierte Felder',
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div ref={btnRef} className="relative flex-shrink-0">
|
|
<button
|
|
onClick={handleToggle}
|
|
title="Gruppierung"
|
|
aria-label="Gruppierung"
|
|
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'}
|
|
`}
|
|
>
|
|
<GroupIcon 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: 'min(420px, 90vw)',
|
|
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">Gruppierung</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>
|
|
|
|
{/* Info text */}
|
|
{activeCount === 0 && (
|
|
<div className="px-4 py-3 border-b border-secondary-100">
|
|
<div className="text-center py-4 text-xs text-secondary-400">
|
|
Keine Gruppierung aktiv. Alle Datensätze werden in einer flachen Liste angezeigt.
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Active group badges */}
|
|
{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 = allGroupFields.find((f) => f.key === cond.field);
|
|
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"
|
|
>
|
|
<span className="font-bold text-primary-500">{idx + 1}.</span>
|
|
{def?.label}
|
|
<button onClick={() => removeCondition(cond.id)} className="hover:text-red-600">
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Group rows */}
|
|
<div className="px-4 py-3 space-y-2">
|
|
{groupState.conditions.map((cond, idx) => (
|
|
<div key={cond.id} className="flex items-center gap-1.5">
|
|
{/* Priority number + reorder */}
|
|
<div className="flex flex-col items-center w-6 flex-shrink-0">
|
|
<span className="text-[10px] font-bold text-primary-600">{idx + 1}</span>
|
|
<div className="flex flex-col">
|
|
<button
|
|
onClick={() => moveCondition(cond.id, 'up')}
|
|
disabled={idx === 0}
|
|
className="text-secondary-300 hover:text-secondary-600 disabled:opacity-30 leading-none"
|
|
>
|
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
|
|
<polyline points="18 15 12 9 6 15" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
onClick={() => moveCondition(cond.id, 'down')}
|
|
disabled={idx === groupState.conditions.length - 1}
|
|
className="text-secondary-300 hover:text-secondary-600 disabled:opacity-30 leading-none"
|
|
>
|
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
|
|
<polyline points="6 9 12 15 18 9" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Field dropdown */}
|
|
<select
|
|
value={cond.field}
|
|
onChange={(e) => updateCondition(cond.id, { field: e.target.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>
|
|
|
|
{/* 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" />
|
|
Gruppierung 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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|