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:
Agent Zero
2026-07-26 03:02:25 +02:00
parent 444c7fdb88
commit a7e3890634
22 changed files with 2684 additions and 8 deletions
@@ -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)}
/>
);
}