diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 0d48f51..902d8bc 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -7,6 +7,7 @@ import WorkflowList from './pages/WorkflowList';
import WorkflowView from './pages/WorkflowView';
import WorkflowEditor from './pages/WorkflowEditor';
import TenantList from './pages/TenantList';
+import TenantDetail from './pages/TenantDetail';
import UserList from './pages/UserList';
function AppLayout({ children }: { children: React.ReactNode }) {
@@ -84,6 +85,17 @@ export default function App() {
}
/>
+
+
+
+
+
+ }
+ />
+
`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
isActive
@@ -49,6 +54,9 @@ export default function Layout({ children }: { children: React.ReactNode }) {
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100'
}`;
+ // Check if we're on a tenant detail page to highlight Mandanten nav
+ const isTenantDetailPage = /^\/tenants\/[^/]+$/.test(location.pathname);
+
const sidebarContent = (
<>
@@ -66,18 +74,42 @@ export default function Layout({ children }: { children: React.ReactNode }) {
- {wf.tenant && (
- {wf.tenant.name}
- )}
- {wf.tenant && · }
{wf._count?.steps ?? wf.steps?.length ?? 0} Schritte · v{wf.version}
diff --git a/frontend/src/pages/TenantDetail.tsx b/frontend/src/pages/TenantDetail.tsx
new file mode 100644
index 0000000..30ca7f2
--- /dev/null
+++ b/frontend/src/pages/TenantDetail.tsx
@@ -0,0 +1,458 @@
+import { useEffect, useState } from 'react';
+import { Link, useNavigate, useParams } from 'react-router-dom';
+import {
+ ArrowLeft,
+ Building2,
+ Check,
+ Clock,
+ Edit,
+ FileCheck,
+ FilePlus,
+ FileText,
+ Grid3X3,
+ List,
+ Plus,
+ Tag,
+ X,
+} from 'lucide-react';
+import toast from 'react-hot-toast';
+import { tenantsApi, workflowsApi } from '../services/api';
+import type { Tenant, Workflow } from '../types';
+import SearchBar from '../components/SearchBar';
+import StatusBadge from '../components/StatusBadge';
+import Pagination from '../components/Pagination';
+import EmptyState from '../components/EmptyState';
+
+interface TenantStats {
+ total: number;
+ published: number;
+ draft: number;
+ review: number;
+}
+
+type ViewMode = 'grid' | 'list';
+
+const statusOptions: { value: string; label: string }[] = [
+ { value: '', label: 'Alle Status' },
+ { value: 'DRAFT', label: 'Entwurf' },
+ { value: 'REVIEW', label: 'In Prüfung' },
+ { value: 'PUBLISHED', label: 'Veröffentlicht' },
+ { value: 'ARCHIVED', label: 'Archiviert' },
+];
+
+export default function TenantDetail() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+
+ const [tenant, setTenant] = useState(null);
+ const [stats, setStats] = useState({ total: 0, published: 0, draft: 0, review: 0 });
+ const [workflows, setWorkflows] = useState([]);
+ const [recentActivity, setRecentActivity] = useState([]);
+ const [search, setSearch] = useState('');
+ const [statusFilter, setStatusFilter] = useState('');
+ const [page, setPage] = useState(1);
+ const [totalPages, setTotalPages] = useState(1);
+ const [loading, setLoading] = useState(true);
+ const [workflowsLoading, setWorkflowsLoading] = useState(true);
+ const [viewMode, setViewMode] = useState('grid');
+
+ // Load tenant info
+ useEffect(() => {
+ if (!id) return;
+ setLoading(true);
+ tenantsApi
+ .get(id)
+ .then((t) => {
+ setTenant(t);
+ })
+ .catch(() => {
+ toast.error('Mandant konnte nicht geladen werden');
+ navigate('/tenants');
+ })
+ .finally(() => setLoading(false));
+ }, [id, navigate]);
+
+ // Load stats
+ useEffect(() => {
+ if (!id) return;
+ Promise.all([
+ workflowsApi.list({ tenantId: id, pageSize: 1 }),
+ workflowsApi.list({ tenantId: id, pageSize: 1, status: 'PUBLISHED' }),
+ workflowsApi.list({ tenantId: id, pageSize: 1, status: 'DRAFT' }),
+ workflowsApi.list({ tenantId: id, pageSize: 1, status: 'REVIEW' }),
+ ])
+ .then(([allRes, pubRes, draftRes, revRes]) => {
+ setStats({
+ total: allRes.total,
+ published: pubRes.total,
+ draft: draftRes.total,
+ review: revRes.total,
+ });
+ })
+ .catch(() => {});
+ }, [id]);
+
+ // Load recent activity (last 5 updated workflows)
+ useEffect(() => {
+ if (!id) return;
+ workflowsApi
+ .list({ tenantId: id, pageSize: 5 })
+ .then((res) => setRecentActivity(res.data))
+ .catch(() => {});
+ }, [id]);
+
+ // Load workflows with filters
+ useEffect(() => {
+ if (!id) return;
+ setWorkflowsLoading(true);
+ workflowsApi
+ .list({
+ tenantId: id,
+ page,
+ pageSize: 12,
+ search: search || undefined,
+ status: statusFilter || undefined,
+ })
+ .then((res) => {
+ setWorkflows(res.data);
+ setTotalPages(res.totalPages);
+ })
+ .catch(() => {
+ toast.error('Workflows konnten nicht geladen werden');
+ })
+ .finally(() => setWorkflowsLoading(false));
+ }, [id, search, statusFilter, page]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!tenant) return null;
+
+ const statCards = [
+ {
+ label: 'Gesamt',
+ value: stats.total,
+ icon: FileText,
+ color: 'bg-primary-50 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400',
+ },
+ {
+ label: 'Veröffentlicht',
+ value: stats.published,
+ icon: FileCheck,
+ color: 'bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-400',
+ },
+ {
+ label: 'Entwurf',
+ value: stats.draft,
+ icon: FilePlus,
+ color: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
+ },
+ {
+ label: 'In Prüfung',
+ value: stats.review,
+ icon: Clock,
+ color: 'bg-yellow-50 text-yellow-600 dark:bg-yellow-900/30 dark:text-yellow-400',
+ },
+ ];
+
+ return (
+
+ {/* Back link */}
+
+
+ {/* Tenant Header */}
+
+
+
+
+ {tenant.name[0]}
+
+
+
+
+ {tenant.name}
+
+ {tenant.active ? (
+
+
+ Aktiv
+
+ ) : (
+
+
+ Inaktiv
+
+ )}
+
+
+ Slug: {tenant.slug}
+
+
+
+
+
+
+
+ {/* Stats Row */}
+
+ {statCards.map((card) => (
+
+
+
+
+ {card.value}
+
+
{card.label}
+
+
+ ))}
+
+
+ {/* Workflows Section */}
+
+
+
+ Workflows
+
+
+ Alle in Workflow-Ansicht
+
+
+
+ {/* Filters */}
+
+
+
+ {
+ setSearch(val);
+ setPage(1);
+ }}
+ placeholder="Workflows suchen..."
+ />
+
+
+
+
+
+
+
+
+
+ {/* Results */}
+ {workflowsLoading ? (
+
+ ) : workflows.length === 0 ? (
+
+
+ Neuer Workflow
+
+ }
+ />
+ ) : viewMode === 'grid' ? (
+
+ {workflows.map((wf) => (
+
+
+
+ {wf.title}
+
+
+
+ {wf.description && (
+
+ {wf.description}
+
+ )}
+
+
+ {wf._count?.steps ?? wf.steps?.length ?? 0} Schritte · v{wf.version}
+
+
+ {new Date(wf.updatedAt).toLocaleDateString('de-DE')}
+
+
+ {wf.category && (
+
+
+ {wf.category.name}
+
+ )}
+ {wf.tags && wf.tags.length > 0 && (
+
+ {wf.tags.slice(0, 3).map((tag) => (
+
+ {tag}
+
+ ))}
+ {wf.tags.length > 3 && (
+
+ +{wf.tags.length - 3}
+
+ )}
+
+ )}
+
+ ))}
+
+ ) : (
+
+ {workflows.map((wf) => (
+
+
+
+
+
+
{wf.title}
+
+ {wf.description}
+
+
+ {wf.category && (
+
+ {wf.category.name}
+
+ )}
+
+ {wf._count?.steps ?? wf.steps?.length ?? 0} Schritte
+
+
+
+ {new Date(wf.updatedAt).toLocaleDateString('de-DE')}
+
+
+ ))}
+
+ )}
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+ )}
+
+
+ {/* Recent Activity */}
+
+
+
+ Letzte Aktivität
+
+
+ {recentActivity.length === 0 ? (
+
+ Noch keine Aktivität vorhanden.
+
+ ) : (
+
+ {recentActivity.map((wf) => (
+ -
+
+
+
+
+
+
+ {wf.title}
+
+
+ {wf._count?.steps ?? wf.steps?.length ?? 0} Schritte · v{wf.version}
+
+
+
+
+ {new Date(wf.updatedAt).toLocaleDateString('de-DE')}
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/TenantList.tsx b/frontend/src/pages/TenantList.tsx
index 3dfc2b6..8099b07 100644
--- a/frontend/src/pages/TenantList.tsx
+++ b/frontend/src/pages/TenantList.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
import {
Building2,
Check,
@@ -8,7 +9,7 @@ import {
X,
} from 'lucide-react';
import toast from 'react-hot-toast';
-import { tenantsApi } from '../services/api';
+import { tenantsApi, workflowsApi } from '../services/api';
import type { Tenant } from '../types';
import Modal from '../components/Modal';
import ConfirmDialog from '../components/ConfirmDialog';
@@ -16,8 +17,14 @@ import SearchBar from '../components/SearchBar';
import Pagination from '../components/Pagination';
import EmptyState from '../components/EmptyState';
+interface TenantWithMeta extends Tenant {
+ workflowCount: number;
+ lastActive: string | null;
+}
+
export default function TenantList() {
- const [tenants, setTenants] = useState([]);
+ const navigate = useNavigate();
+ const [tenants, setTenants] = useState([]);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
@@ -47,7 +54,27 @@ export default function TenantList() {
pageSize: 20,
search: search || undefined,
});
- setTenants(res.data);
+ // Enrich each tenant with workflow count and last active date
+ const enriched: TenantWithMeta[] = await Promise.all(
+ res.data.map(async (tenant) => {
+ try {
+ const wfRes = await workflowsApi.list({ tenantId: tenant.id, pageSize: 1 });
+ const lastActive = wfRes.data.length > 0 ? wfRes.data[0].updatedAt : null;
+ return {
+ ...tenant,
+ workflowCount: wfRes.total,
+ lastActive,
+ };
+ } catch {
+ return {
+ ...tenant,
+ workflowCount: 0,
+ lastActive: null,
+ };
+ }
+ })
+ );
+ setTenants(enriched);
setTotalPages(res.totalPages);
} catch {
toast.error('Mandanten konnten nicht geladen werden');
@@ -64,7 +91,8 @@ export default function TenantList() {
setModalOpen(true);
};
- const openEdit = (tenant: Tenant) => {
+ const openEdit = (tenant: Tenant, e: React.MouseEvent) => {
+ e.stopPropagation();
setEditingTenant(tenant);
setFormName(tenant.name);
setFormSlug(tenant.slug);
@@ -124,6 +152,10 @@ export default function TenantList() {
}
};
+ const handleRowClick = (tenantId: string) => {
+ navigate(`/tenants/${tenantId}`);
+ };
+
return (
{/* Header */}
@@ -181,6 +213,12 @@ export default function TenantList() {
Slug
|
+
+ Workflows
+ |
+
+ Zuletzt aktiv
+ |
Status
|
@@ -191,7 +229,11 @@ export default function TenantList() {
{tenants.map((tenant) => (
-
+
handleRowClick(tenant.id)}
+ className="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
+ >
@@ -205,6 +247,16 @@ export default function TenantList() {
{tenant.slug}
|
+
+
+ {tenant.workflowCount}
+
+ |
+
+ {tenant.lastActive
+ ? new Date(tenant.lastActive).toLocaleDateString('de-DE')
+ : '-'}
+ |
{tenant.active ? (
@@ -221,14 +273,17 @@ export default function TenantList() {
|
+ {/* Tenant breadcrumb chip when filtered */}
+ {isAdmin && activeTenant && (
+
+ Mandant:
+
+
+ {activeTenant.name}
+
+
+
+ )}
+
{/* Filters */}
| |