Phase 2: Tags UI, Custom Fields UI, Notifications Bell
- Tags UI: TagsPage (CRUD, color picker), TagBadge, TagSelector (multi-select, inline creation) - Custom Fields Backend: model, schema, service, routes, migration 0041 - Custom Fields Frontend: CustomFieldsPage (definitions CRUD), CustomFieldRenderer (dynamic field rendering) - Custom Fields: _collect_custom_field_definitions() extended to merge DB definitions with plugin definitions - Notifications Bell: NotificationBell (30s polling, unread badge), NotificationDropdown, NotificationItem - NotificationBell integrated into TopBar - Routes: /tags, /settings/custom-fields registered - Settings nav: Custom Fields entry added - Menu items: Tags added to automation plugin manifest
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Dynamic custom field renderer.
|
||||
* Renders the appropriate input element based on field_type.
|
||||
*/
|
||||
|
||||
import React, { useId } from 'react';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { X } from 'lucide-react';
|
||||
import type { CustomFieldDefinition } from '@/api/customFieldDefinitions';
|
||||
|
||||
export interface CustomFieldRendererProps {
|
||||
definition: CustomFieldDefinition;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
}
|
||||
|
||||
export function CustomFieldRenderer({ definition, value, onChange }: CustomFieldRendererProps) {
|
||||
const generatedId = useId();
|
||||
const fieldId = `cf-${definition.id || generatedId}`;
|
||||
const { field_type, options, required } = definition;
|
||||
|
||||
// --- Boolean: checkbox ---
|
||||
if (field_type === 'boolean') {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<label
|
||||
htmlFor={fieldId}
|
||||
className="flex items-center gap-2 text-sm font-medium text-secondary-700 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
id={fieldId}
|
||||
type="checkbox"
|
||||
checked={!!value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
required={required}
|
||||
className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span>
|
||||
{definition.label}
|
||||
{required && <span className="text-danger-500 ml-1" aria-label="required">*</span>}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Multiselect: chips with toggle ---
|
||||
if (field_type === 'multiselect') {
|
||||
const selectedValues: string[] = Array.isArray(value)
|
||||
? value
|
||||
: value != null && value !== ''
|
||||
? [String(value)]
|
||||
: [];
|
||||
const availableOptions = options || [];
|
||||
|
||||
const toggleOption = (opt: string) => {
|
||||
if (selectedValues.includes(opt)) {
|
||||
onChange(selectedValues.filter((v) => v !== opt));
|
||||
} else {
|
||||
onChange([...selectedValues, opt]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeChip = (opt: string) => {
|
||||
onChange(selectedValues.filter((v) => v !== opt));
|
||||
};
|
||||
|
||||
const unselected = availableOptions.filter((o) => !selectedValues.includes(o));
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{definition.label}
|
||||
{required && <span className="text-danger-500 ml-1" aria-label="required">*</span>}
|
||||
</label>
|
||||
{selectedValues.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{selectedValues.map((opt) => (
|
||||
<Badge key={opt} variant="primary" className="gap-1">
|
||||
{opt}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeChip(opt)}
|
||||
className="inline-flex items-center justify-center rounded-full hover:bg-primary-200"
|
||||
aria-label={`Remove ${opt}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{unselected.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{unselected.map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => toggleOption(opt)}
|
||||
className="px-2.5 py-0.5 rounded-full text-xs font-medium border border-secondary-300 text-secondary-700 hover:bg-secondary-100"
|
||||
>
|
||||
+ {opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : availableOptions.length === 0 ? (
|
||||
<p className="text-sm text-secondary-400">Keine Optionen verfügbar</p>
|
||||
) : (
|
||||
<p className="text-sm text-secondary-400">Alle Optionen ausgewählt</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Select: dropdown ---
|
||||
if (field_type === 'select') {
|
||||
const selectOptions = (options || []).map((opt) => ({ value: opt, label: opt }));
|
||||
return (
|
||||
<Select
|
||||
id={fieldId}
|
||||
label={definition.label}
|
||||
required={required}
|
||||
options={selectOptions}
|
||||
placeholder="— Bitte wählen —"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Number: numeric input ---
|
||||
if (field_type === 'number') {
|
||||
return (
|
||||
<Input
|
||||
id={fieldId}
|
||||
label={definition.label}
|
||||
required={required}
|
||||
type="number"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
onChange(raw === '' ? null : Number(raw));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Date: date input ---
|
||||
if (field_type === 'date') {
|
||||
return (
|
||||
<Input
|
||||
id={fieldId}
|
||||
label={definition.label}
|
||||
required={required}
|
||||
type="date"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Text (default) ---
|
||||
return (
|
||||
<Input
|
||||
id={fieldId}
|
||||
label={definition.label}
|
||||
required={required}
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* NotificationBell — bell icon with unread badge + dropdown.
|
||||
*
|
||||
* - Uses useUnreadNotificationCount() with 30 s polling
|
||||
* - Bell icon (lucide-react Bell) with red badge count if > 0
|
||||
* - Click toggles dropdown
|
||||
* - Click outside closes dropdown
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Bell } from 'lucide-react';
|
||||
import { useUnreadNotificationCount } from '@/api/notifications';
|
||||
import { NotificationDropdown } from '@/components/notifications/NotificationDropdown';
|
||||
|
||||
export function NotificationBell() {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: unreadCount } = useUnreadNotificationCount({
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [open]);
|
||||
|
||||
const count = unreadCount ?? 0;
|
||||
const displayCount = count > 99 ? '99+' : String(count);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="relative p-2 rounded-md hover:bg-secondary-100 text-secondary-600 min-h-touch min-w-touch flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
aria-label={t('notifications.title', 'Benachrichtigungen')}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<Bell className="w-5 h-5" strokeWidth={2} aria-hidden="true" />
|
||||
|
||||
{count > 0 && (
|
||||
<span
|
||||
className="absolute -top-0.5 -right-0.5 flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-danger-500 text-white text-[10px] font-bold leading-none"
|
||||
aria-label={`${count} ungelesene Benachrichtigungen`}
|
||||
>
|
||||
{displayCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && <NotificationDropdown />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Avatar } from '@/components/ui/Avatar';
|
||||
import { SearchDropdown } from '@/components/shared/SearchDropdown';
|
||||
import { SuggestionBadge } from '@/components/ai/SuggestionBadge';
|
||||
import { Building, ChevronDown, Menu, Zap, Bot, Layers } from 'lucide-react';
|
||||
import { NotificationBell } from '@/components/layout/NotificationBell';
|
||||
import { useWindowStore } from '@/store/windowStore';
|
||||
|
||||
export function TopBar() {
|
||||
@@ -80,6 +81,7 @@ export function TopBar() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationBell />
|
||||
{/* Minimized windows */}
|
||||
{minimizedWindows.length > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* NotificationDropdown — panel that lists notifications inside the bell dropdown.
|
||||
*
|
||||
* Features:
|
||||
* - Uses useNotifications() to list notification items
|
||||
* - "Alle als gelesen" button marks all unread notifications as read
|
||||
* - "Alle anzeigen" link navigates to /settings/notifications
|
||||
* - Loading skeleton, empty state
|
||||
* - Max height with scroll
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Bell, CheckCheck, Settings, AlertCircle } from 'lucide-react';
|
||||
import { useNotifications, useMarkNotificationRead } from '@/api/notifications';
|
||||
import { NotificationItem } from './NotificationItem';
|
||||
|
||||
export function NotificationDropdown() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, isError } = useNotifications();
|
||||
const markReadMutation = useMarkNotificationRead();
|
||||
|
||||
const notifications = data?.items ?? [];
|
||||
const hasUnread = notifications.some((n) => n.read_at == null);
|
||||
|
||||
const handleMarkAllRead = () => {
|
||||
notifications.forEach((n) => {
|
||||
if (n.read_at == null) {
|
||||
markReadMutation.mutate(n.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleNavigateAll = () => {
|
||||
navigate('/settings/notifications');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute top-full right-0 mt-1 w-80 bg-white rounded-md shadow-lg border border-secondary-200 z-50"
|
||||
role="menu"
|
||||
aria-label={t('notifications.title', 'Benachrichtigungen')}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-secondary-200">
|
||||
<h3 className="text-sm font-semibold text-secondary-900">
|
||||
{t('notifications.title', 'Benachrichtigungen')}
|
||||
</h3>
|
||||
{hasUnread && (
|
||||
<button
|
||||
onClick={handleMarkAllRead}
|
||||
disabled={markReadMutation.isPending}
|
||||
className="flex items-center gap-1 text-xs text-primary-600 hover:text-primary-700 font-medium disabled:opacity-50 disabled:cursor-not-allowed min-h-touch px-1 py-0.5 rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
aria-label={t('notifications.markAllRead', 'Alle als gelesen')}
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
{t('notifications.markAllRead', 'Alle als gelesen')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{isLoading && <NotificationSkeleton />}
|
||||
|
||||
{isError && (
|
||||
<div className="px-3 py-6 text-center">
|
||||
<AlertCircle className="w-6 h-6 text-danger-400 mx-auto mb-2" strokeWidth={2} />
|
||||
<p className="text-sm text-secondary-500">
|
||||
{t('notifications.errorLoading', 'Fehler beim Laden der Benachrichtigungen')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && notifications.length === 0 && (
|
||||
<div className="px-3 py-8 text-center">
|
||||
<Bell className="w-8 h-8 text-secondary-300 mx-auto mb-2" strokeWidth={2} />
|
||||
<p className="text-sm text-secondary-500">
|
||||
{t('notifications.empty', 'Keine Benachrichtigungen')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && notifications.length > 0 && (
|
||||
<div role="menu">
|
||||
{notifications.map((n) => (
|
||||
<NotificationItem
|
||||
key={n.id}
|
||||
notification={n}
|
||||
onMarkRead={(id) => markReadMutation.mutate(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-secondary-200 px-3 py-2">
|
||||
<button
|
||||
onClick={handleNavigateAll}
|
||||
className="w-full flex items-center justify-center gap-1.5 text-sm text-primary-600 hover:text-primary-700 font-medium py-1.5 rounded-md hover:bg-primary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
aria-label={t('notifications.viewAll', 'Alle anzeigen')}
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
{t('notifications.viewAll', 'Alle anzeigen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── internal: loading skeleton ──
|
||||
|
||||
function NotificationSkeleton() {
|
||||
return (
|
||||
<div className="px-3 py-2" aria-hidden="true">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="flex items-start gap-3 py-2.5 border-b border-secondary-100 last:border-b-0">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-secondary-100 animate-pulse" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3.5 bg-secondary-100 rounded animate-pulse w-3/4" />
|
||||
<div className="h-2.5 bg-secondary-100 rounded animate-pulse w-1/2" />
|
||||
<div className="h-2 bg-secondary-100 rounded animate-pulse w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* NotificationItem — single notification row inside the dropdown.
|
||||
*
|
||||
* Props:
|
||||
* notification: NotificationItem
|
||||
* onMarkRead: (id: string) => void
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
Info,
|
||||
Mail,
|
||||
CheckSquare,
|
||||
Calendar,
|
||||
AlertCircle,
|
||||
User,
|
||||
FileText,
|
||||
Bell,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import type { NotificationItem as NotificationItemType } from '@/api/notifications';
|
||||
|
||||
// ── helpers ──
|
||||
|
||||
/**
|
||||
* Returns a German relative-time string like "vor 5 Min" or "vor 2 Stunden".
|
||||
* Falls back to "gerade eben" for < 1 min and an absolute date for > 7 days.
|
||||
*/
|
||||
function relativeTime(isoDate: string | null | undefined): string {
|
||||
if (!isoDate) return '';
|
||||
const now = Date.now();
|
||||
const then = new Date(isoDate).getTime();
|
||||
if (Number.isNaN(then)) return '';
|
||||
const diffMs = now - then;
|
||||
if (diffMs < 0) return 'gerade eben';
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
if (diffMin < 1) return 'gerade eben';
|
||||
if (diffMin < 60) return `vor ${diffMin} Min`;
|
||||
const diffHrs = Math.floor(diffMin / 60);
|
||||
if (diffHrs < 24) return `vor ${diffHrs} Std`;
|
||||
const diffDays = Math.floor(diffHrs / 24);
|
||||
if (diffDays < 7) return `vor ${diffDays} ${diffDays === 1 ? 'Tag' : 'Tagen'}`;
|
||||
// > 7 days: show absolute date
|
||||
return new Date(isoDate).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
/** Map notification type → lucide icon. */
|
||||
const typeIconMap: Record<string, LucideIcon> = {
|
||||
info: Info,
|
||||
email: Mail,
|
||||
mail: Mail,
|
||||
task: CheckSquare,
|
||||
calendar: Calendar,
|
||||
event: Calendar,
|
||||
alert: AlertCircle,
|
||||
warning: AlertCircle,
|
||||
error: AlertCircle,
|
||||
contact: User,
|
||||
user: User,
|
||||
document: FileText,
|
||||
file: FileText,
|
||||
};
|
||||
|
||||
function getIconForType(type: string): LucideIcon {
|
||||
return typeIconMap[type] ?? Bell;
|
||||
}
|
||||
|
||||
// ── component ──
|
||||
|
||||
export interface NotificationItemProps {
|
||||
notification: NotificationItemType;
|
||||
onMarkRead: (id: string) => void;
|
||||
}
|
||||
|
||||
export function NotificationItem({ notification, onMarkRead }: NotificationItemProps) {
|
||||
const isUnread = notification.read_at == null;
|
||||
const Icon = getIconForType(notification.type);
|
||||
|
||||
const handleClick = () => {
|
||||
if (isUnread) {
|
||||
onMarkRead(notification.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={clsx(
|
||||
'w-full text-left flex items-start gap-3 px-3 py-2.5 hover:bg-secondary-50 transition-colors cursor-pointer',
|
||||
'border-b border-secondary-100 last:border-b-0 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
isUnread && 'bg-primary-50/40',
|
||||
)}
|
||||
role="menuitem"
|
||||
aria-label={notification.title}
|
||||
>
|
||||
{/* Icon */}
|
||||
<span
|
||||
className={clsx(
|
||||
'flex-shrink-0 mt-0.5 w-8 h-8 rounded-full flex items-center justify-center',
|
||||
isUnread ? 'bg-primary-100 text-primary-600' : 'bg-secondary-100 text-secondary-500',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon className="w-4 h-4" strokeWidth={2} />
|
||||
</span>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className={clsx(
|
||||
'text-sm truncate',
|
||||
isUnread ? 'font-semibold text-secondary-900' : 'font-medium text-secondary-700',
|
||||
)}
|
||||
>
|
||||
{notification.title}
|
||||
</p>
|
||||
{isUnread && (
|
||||
<span
|
||||
className="flex-shrink-0 w-2 h-2 rounded-full bg-primary-500"
|
||||
aria-label="ungelesen"
|
||||
title="ungelesen"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{notification.body && (
|
||||
<p className="text-xs text-secondary-500 truncate mt-0.5">
|
||||
{notification.body}
|
||||
</p>
|
||||
)}
|
||||
{notification.created_at && (
|
||||
<p className="text-xs text-secondary-400 mt-1">
|
||||
{relativeTime(notification.created_at)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* TagBadge — Reusable colored badge for displaying a tag.
|
||||
*
|
||||
* Renders a colored pill with the tag name and an optional remove (X) button.
|
||||
* Colors are mapped from the tag's `color` string (e.g. "red", "blue") to
|
||||
* Tailwind classes. Unknown colors fall back to gray.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
// ─── Color Mapping ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface TagColorClasses {
|
||||
bg: string;
|
||||
text: string;
|
||||
dot: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
/** Static map of predefined tag color names → Tailwind classes. */
|
||||
const TAG_COLOR_MAP: Record<string, TagColorClasses> = {
|
||||
red: {
|
||||
bg: 'bg-red-100',
|
||||
text: 'text-red-800',
|
||||
dot: 'bg-red-500',
|
||||
border: 'border-red-300',
|
||||
},
|
||||
blue: {
|
||||
bg: 'bg-blue-100',
|
||||
text: 'text-blue-800',
|
||||
dot: 'bg-blue-500',
|
||||
border: 'border-blue-300',
|
||||
},
|
||||
green: {
|
||||
bg: 'bg-green-100',
|
||||
text: 'text-green-800',
|
||||
dot: 'bg-green-500',
|
||||
border: 'border-green-300',
|
||||
},
|
||||
yellow: {
|
||||
bg: 'bg-yellow-100',
|
||||
text: 'text-yellow-800',
|
||||
dot: 'bg-yellow-500',
|
||||
border: 'border-yellow-300',
|
||||
},
|
||||
purple: {
|
||||
bg: 'bg-purple-100',
|
||||
text: 'text-purple-800',
|
||||
dot: 'bg-purple-500',
|
||||
border: 'border-purple-300',
|
||||
},
|
||||
pink: {
|
||||
bg: 'bg-pink-100',
|
||||
text: 'text-pink-800',
|
||||
dot: 'bg-pink-500',
|
||||
border: 'border-pink-300',
|
||||
},
|
||||
orange: {
|
||||
bg: 'bg-orange-100',
|
||||
text: 'text-orange-800',
|
||||
dot: 'bg-orange-500',
|
||||
border: 'border-orange-300',
|
||||
},
|
||||
gray: {
|
||||
bg: 'bg-gray-100',
|
||||
text: 'text-gray-800',
|
||||
dot: 'bg-gray-500',
|
||||
border: 'border-gray-300',
|
||||
},
|
||||
};
|
||||
|
||||
/** All predefined color names (used by Tags page and TagSelector). */
|
||||
export const TAG_COLOR_NAMES = Object.keys(TAG_COLOR_MAP);
|
||||
|
||||
/**
|
||||
* Resolve Tailwind classes for a tag color name.
|
||||
* Falls back to gray for unknown colors.
|
||||
*/
|
||||
export function getTagColorClasses(color: string): TagColorClasses {
|
||||
return TAG_COLOR_MAP[color] ?? TAG_COLOR_MAP.gray;
|
||||
}
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TagBadgeProps {
|
||||
/** Tag data — only name and color are required. */
|
||||
tag: { name: string; color: string };
|
||||
/** Optional remove handler. When provided, an X button is shown. */
|
||||
onRemove?: () => void;
|
||||
/** Badge size. */
|
||||
size?: 'sm' | 'md';
|
||||
/** Extra classes. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'text-xs px-2 py-0.5 gap-1',
|
||||
md: 'text-sm px-2.5 py-1 gap-1.5',
|
||||
};
|
||||
|
||||
const dotSizeClasses = {
|
||||
sm: 'w-1.5 h-1.5',
|
||||
md: 'w-2 h-2',
|
||||
};
|
||||
|
||||
const removeIconSize = {
|
||||
sm: 'h-3 w-3',
|
||||
md: 'h-3.5 w-3.5',
|
||||
};
|
||||
|
||||
export function TagBadge({ tag, onRemove, size = 'md', className }: TagBadgeProps) {
|
||||
const colors = getTagColorClasses(tag.color);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center rounded-full border font-medium',
|
||||
colors.bg,
|
||||
colors.text,
|
||||
colors.border,
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
data-testid="tag-badge"
|
||||
>
|
||||
<span
|
||||
className={clsx('rounded-full flex-shrink-0', colors.dot, dotSizeClasses[size])}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate max-w-[200px]">{tag.name}</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center rounded-full hover:bg-black/10 flex-shrink-0',
|
||||
'focus:outline-none focus-visible:ring-1 focus-visible:ring-current min-w-touch min-h-touch',
|
||||
removeIconSize[size]
|
||||
)}
|
||||
aria-label={`Remove tag ${tag.name}`}
|
||||
>
|
||||
<X className={removeIconSize[size]} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* TagSelector — Multi-select tag picker with dropdown.
|
||||
*
|
||||
* Features:
|
||||
* - Dropdown showing all available tags with checkboxes
|
||||
* - Search/filter within the dropdown
|
||||
* - Selected tags shown as TagBadge chips
|
||||
* - Inline "Neues Tag" creation (name + color → createTag)
|
||||
* - Click outside to close
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import clsx from 'clsx';
|
||||
import { ChevronDown, Check, Plus, Search, Tag as TagIcon } from 'lucide-react';
|
||||
import { fetchTags, createTag, type Tag, type CreateTagPayload } from '@/api/tags';
|
||||
import { TagBadge, getTagColorClasses, TAG_COLOR_NAMES } from './TagBadge';
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TagSelectorProps {
|
||||
/** Entity type for assignment (e.g. "contact", "file"). */
|
||||
entityType: string;
|
||||
/** Entity ID for assignment. */
|
||||
entityId: string;
|
||||
/** Currently selected tags. */
|
||||
selectedTags: Tag[];
|
||||
/** Callback when selection changes. */
|
||||
onChange: (tags: Tag[]) => void;
|
||||
/** Optional placeholder text. */
|
||||
placeholder?: string;
|
||||
/** Optional className for the wrapper. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TagSelector({
|
||||
entityType,
|
||||
entityId,
|
||||
selectedTags,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
}: TagSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [newTagName, setNewTagName] = useState('');
|
||||
const [newTagColor, setNewTagColor] = useState('blue');
|
||||
|
||||
// ─── Refs ─────────────────────────────────────────────────────────────────
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ─── Queries ──────────────────────────────────────────────────────────────
|
||||
const { data: allTags = [], isLoading: loadingTags } = useQuery<Tag[]>({
|
||||
queryKey: ['tags'],
|
||||
queryFn: fetchTags,
|
||||
});
|
||||
|
||||
// ─── Mutations ────────────────────────────────────────────────────────────
|
||||
const createTagMutation = useMutation<Tag, Error, CreateTagPayload>({
|
||||
mutationFn: createTag,
|
||||
onSuccess: (newTag) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
||||
// Auto-select the newly created tag
|
||||
onChange([...selectedTags, newTag]);
|
||||
setNewTagName('');
|
||||
setNewTagColor('blue');
|
||||
setShowCreateForm(false);
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Click outside handler ────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
setShowCreateForm(false);
|
||||
setNewTagName('');
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isOpen]);
|
||||
|
||||
// ─── Focus search input on open ───────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (isOpen && !showCreateForm) {
|
||||
const timer = setTimeout(() => searchInputRef.current?.focus(), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isOpen, showCreateForm]);
|
||||
|
||||
// ─── Derived data ─────────────────────────────────────────────────────────
|
||||
const selectedTagIds = useMemo(() => new Set(selectedTags.map((tag) => tag.id)), [selectedTags]);
|
||||
|
||||
const filteredTags = useMemo(() => {
|
||||
if (!searchQuery.trim()) return allTags;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return allTags.filter(
|
||||
(tag) =>
|
||||
tag.name.toLowerCase().includes(q) ||
|
||||
(tag.description?.toLowerCase().includes(q) ?? false)
|
||||
);
|
||||
}, [allTags, searchQuery]);
|
||||
|
||||
// ─── Handlers ─────────────────────────────────────────────────────────────
|
||||
const toggleTag = useCallback(
|
||||
(tag: Tag) => {
|
||||
if (selectedTagIds.has(tag.id)) {
|
||||
onChange(selectedTags.filter((t) => t.id !== tag.id));
|
||||
} else {
|
||||
onChange([...selectedTags, tag]);
|
||||
}
|
||||
},
|
||||
[selectedTagIds, selectedTags, onChange]
|
||||
);
|
||||
|
||||
const handleRemoveTag = useCallback(
|
||||
(tagId: string) => {
|
||||
onChange(selectedTags.filter((t) => t.id !== tagId));
|
||||
},
|
||||
[selectedTags, onChange]
|
||||
);
|
||||
|
||||
const handleCreateTag = useCallback(() => {
|
||||
const name = newTagName.trim();
|
||||
if (!name) return;
|
||||
createTagMutation.mutate({ name, color: newTagColor });
|
||||
}, [newTagName, newTagColor, createTagMutation]);
|
||||
|
||||
const handleOpenChange = useCallback(() => {
|
||||
setIsOpen((prev) => !prev);
|
||||
if (isOpen) {
|
||||
setShowCreateForm(false);
|
||||
setNewTagName('');
|
||||
setSearchQuery('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────
|
||||
// Suppress unused variable warnings for props that are used for context
|
||||
void entityType;
|
||||
void entityId;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={clsx('relative', className)} data-testid="tag-selector">
|
||||
{/* Selected tags chips + dropdown toggle */}
|
||||
<div
|
||||
className={clsx(
|
||||
'min-h-[2.5rem] w-full rounded-md border border-secondary-300 bg-white px-2 py-1.5',
|
||||
'flex flex-wrap items-center gap-1 cursor-text',
|
||||
'focus-within:ring-2 focus-within:ring-primary-500 focus-within:border-primary-500',
|
||||
'transition-colors'
|
||||
)}
|
||||
onClick={handleOpenChange}
|
||||
>
|
||||
{selectedTags.length === 0 && (
|
||||
<span className="text-sm text-secondary-400 px-1">
|
||||
{placeholder || t('tags.selectPlaceholder', 'Tags auswählen...')}
|
||||
</span>
|
||||
)}
|
||||
{selectedTags.map((tag) => (
|
||||
<TagBadge
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
size="sm"
|
||||
onRemove={() => handleRemoveTag(tag.id)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
'ml-auto inline-flex items-center justify-center rounded p-1',
|
||||
'text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500'
|
||||
)}
|
||||
aria-label={t('tags.toggleDropdown', 'Tags auswählen')}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<ChevronDown className={clsx('h-4 w-4 transition-transform', isOpen && 'rotate-180')} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dropdown panel */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute z-50 mt-1 w-full rounded-md border border-secondary-200 bg-white shadow-lg',
|
||||
'max-h-80 overflow-hidden flex flex-col'
|
||||
)}
|
||||
data-testid="tag-selector-dropdown"
|
||||
>
|
||||
{/* Search bar */}
|
||||
<div className="p-2 border-b border-secondary-100">
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-secondary-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('tags.search', 'Tag suchen...')}
|
||||
className={clsx(
|
||||
'w-full rounded-md border border-secondary-300 pl-8 pr-3 py-1.5 text-sm',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500'
|
||||
)}
|
||||
data-testid="tag-selector-search"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag list or create form */}
|
||||
{!showCreateForm ? (
|
||||
<>
|
||||
<div className="overflow-y-auto flex-1 max-h-56">
|
||||
{loadingTags && (
|
||||
<div className="px-3 py-4 text-sm text-secondary-500 text-center">
|
||||
{t('common.loading', 'Laden...')}
|
||||
</div>
|
||||
)}
|
||||
{!loadingTags && filteredTags.length === 0 && (
|
||||
<div className="px-3 py-4 text-sm text-secondary-500 text-center">
|
||||
{t('tags.noTagsFound', 'Keine Tags gefunden.')}
|
||||
</div>
|
||||
)}
|
||||
{!loadingTags &&
|
||||
filteredTags.map((tag) => {
|
||||
const isSelected = selectedTagIds.has(tag.id);
|
||||
const colors = getTagColorClasses(tag.color);
|
||||
return (
|
||||
<label
|
||||
key={tag.id}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-secondary-50',
|
||||
'transition-colors'
|
||||
)}
|
||||
data-testid={`tag-option-${tag.id}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleTag(tag)}
|
||||
className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span
|
||||
className={clsx('rounded-full flex-shrink-0', colors.dot, 'w-2.5 h-2.5')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-sm text-secondary-800 truncate flex-1">{tag.name}</span>
|
||||
{tag.usage_count !== undefined && tag.usage_count > 0 && (
|
||||
<span className="text-xs text-secondary-400">{tag.usage_count}×</span>
|
||||
)}
|
||||
{isSelected && (
|
||||
<Check className="h-4 w-4 text-primary-600" aria-hidden="true" />
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Create new tag button */}
|
||||
<div className="border-t border-secondary-100 p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 w-full rounded-md px-3 py-2 text-sm font-medium',
|
||||
'text-primary-600 hover:bg-primary-50 transition-colors',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500'
|
||||
)}
|
||||
data-testid="tag-selector-create-btn"
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
{t('tags.createInline', 'Neues Tag')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Inline create form */
|
||||
<div className="p-3 space-y-3" data-testid="tag-create-inline">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleCreateTag();
|
||||
}
|
||||
}}
|
||||
placeholder={t('tags.namePlaceholder', 'Tag-Name')}
|
||||
className={clsx(
|
||||
'w-full rounded-md border border-secondary-300 px-3 py-2 text-sm',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500'
|
||||
)}
|
||||
autoFocus
|
||||
data-testid="tag-create-name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{TAG_COLOR_NAMES.map((colorName) => {
|
||||
const colors = getTagColorClasses(colorName);
|
||||
return (
|
||||
<button
|
||||
key={colorName}
|
||||
type="button"
|
||||
onClick={() => setNewTagColor(colorName)}
|
||||
className={clsx(
|
||||
'rounded-full transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary-500',
|
||||
colors.dot,
|
||||
'w-6 h-6',
|
||||
newTagColor === colorName
|
||||
? 'ring-2 ring-offset-1 ring-secondary-400 scale-110'
|
||||
: 'hover:scale-110'
|
||||
)}
|
||||
aria-label={colorName}
|
||||
aria-pressed={newTagColor === colorName}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setNewTagName('');
|
||||
}}
|
||||
className={clsx(
|
||||
'rounded-md px-3 py-1.5 text-sm font-medium',
|
||||
'text-secondary-600 hover:bg-secondary-100 transition-colors',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-secondary-500'
|
||||
)}
|
||||
>
|
||||
{t('common.cancel', 'Abbrechen')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateTag}
|
||||
disabled={!newTagName.trim() || createTagMutation.isPending}
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium',
|
||||
'bg-primary-600 text-white hover:bg-primary-700 transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500'
|
||||
)}
|
||||
data-testid="tag-create-submit"
|
||||
>
|
||||
{createTagMutation.isPending ? (
|
||||
t('common.creating', 'Erstelle...')
|
||||
) : (
|
||||
<>
|
||||
<TagIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t('tags.create', 'Erstellen')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{createTagMutation.isError && (
|
||||
<p className="text-sm text-danger-600">
|
||||
{createTagMutation.error?.message || t('tags.createError', 'Fehler beim Erstellen.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user