Phase 3: Plugin-UI-System (WordPress-Style)
Backend: - PluginManifest um 5 neue UI-Felder erweitert: menu_items, page_routes, detail_tabs, settings_pages, dashboard_widgets (FrontendMenuItem, FrontendPageRoute, FrontendDetailTab, FrontendSettingsPage, FrontendDashboardWidget) - GET /api/v1/plugins/active-manifests Endpoint liefert UI-Manifeste aller aktiven Plugins - Registry.get_active_manifests() + PluginService.get_active_manifests() - 12 Built-in Plugins mit UI-Manifest-Daten gefuellt (menu_items, page_routes, detail_tabs, settings_pages) - Plugin-Install-System: POST /upload (ZIP), POST /install-url (URL) mit Validierung (Manifest, dangerous imports, SQL migrations) Frontend: - pluginStore.ts (Zustand) mit PluginUiManifest Typen + Selektoren - useActivePluginManifests() React Query Hook - PluginRegistry.tsx — fetcht Manifeste beim App-Start - PluginLoader.tsx — dynamisches React.lazy() mit ErrorBoundary - PluginRouteRenderer.tsx — Catch-all fuer Plugin-Routes - routes/index.tsx — Catch-all Routes fuer Plugin-Pages + Settings - Sidebar.tsx — dynamische Plugin Menu-Items mit Grouping + Icons - Settings.tsx — dynamische Plugin Settings-Pages - ContactDetail.tsx — dynamische Plugin Detail-Tabs mit Permissions - AppShell.tsx — PluginRegistry Provider eingebunden - SettingsPlugins.tsx — Install-UI (ZIP Upload + URL Install) - plugins.ts — useUploadPlugin() + useInstallPluginFromUrl() Hooks Docs & Templates: - docs/plugin-development-guide.md — komplette Entwickler-Doku - templates/plugin-template/ — Boilerplate mit allen Manifest-Feldern Tests: - 34 Vitest-Tests (PluginRegistry, PluginLoader, PluginRouteRenderer, pluginStore) — alle bestanden - TSC: keine neuen Errors (nur pre-existing Dms.tsx)
This commit is contained in:
@@ -8,7 +8,11 @@ import { Input } from '@/components/ui/Input';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { HistoryViewer } from '@/components/HistoryViewer';
|
||||
import { usePluginStore } from '@/store/pluginStore';
|
||||
import { PluginPage } from '@/components/plugins/PluginLoader';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import {
|
||||
type UnifiedContact,
|
||||
type ContactPerson,
|
||||
@@ -135,6 +139,11 @@ function ContactPersonModal({
|
||||
);
|
||||
}
|
||||
|
||||
function getIcon(name: string): React.ReactNode {
|
||||
const Icon = (LucideIcons as any)[name];
|
||||
return Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.FileText className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDetailProps) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
@@ -144,6 +153,9 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
|
||||
const deletePersonMutation = useDeleteContactPerson();
|
||||
const [personModalOpen, setPersonModalOpen] = useState(false);
|
||||
const [editingPerson, setEditingPerson] = useState<ContactPerson | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('details');
|
||||
const pluginTabs = usePluginStore(s => s.getDetailTabsForEntity('contact'));
|
||||
const user = useAuthStore(s => s.user);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -203,6 +215,21 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
|
||||
}
|
||||
};
|
||||
|
||||
const visiblePluginTabs = pluginTabs.filter(tab => {
|
||||
if (!tab.permission) return true;
|
||||
return user?.permissions?.includes(tab.permission) ?? false;
|
||||
});
|
||||
|
||||
const allTabs = [
|
||||
{ id: 'details', label: t('contacts.details', 'Details'), icon: null },
|
||||
...visiblePluginTabs.map(tab => ({
|
||||
id: tab.component,
|
||||
label: t(tab.label_key, tab.label),
|
||||
icon: tab.icon,
|
||||
component: tab.component,
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto h-full" data-testid="contact-detail">
|
||||
{/* Header */}
|
||||
@@ -224,7 +251,28 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{/* Tab Bar */}
|
||||
<div className="flex border-b border-secondary-200 bg-white sticky top-[58px] z-10 overflow-x-auto" role="tablist">
|
||||
{allTabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.id}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary-500 text-primary-700'
|
||||
: 'border-transparent text-secondary-500 hover:text-secondary-700 hover:border-secondary-300'
|
||||
}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.icon && getIcon(tab.icon)}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'details' ? (
|
||||
<div className="p-4">
|
||||
{/* Contact Persons */}
|
||||
<Section
|
||||
title={t('contacts.contactPersons')}
|
||||
@@ -367,6 +415,19 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4">
|
||||
{allTabs.filter((t): t is { id: string; label: string; icon: string; component: string } => t.id !== 'details').map(tab => (
|
||||
activeTab === tab.id && (
|
||||
<PluginPage
|
||||
key={tab.id}
|
||||
component={tab.component}
|
||||
pluginName={tab.label}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ContactPersonModal
|
||||
open={personModalOpen}
|
||||
|
||||
Reference in New Issue
Block a user