feat: mandantenzentrische UI-Umstellung

- Dashboard zeigt Mandanten-Karten statt Workflow-Statistiken
- Neue /tenants/:id Detail-Seite (Workflows pro Mandant)
- Sidebar: Mandanten als primäre Navigation
- TenantList: klickbare Zeilen → Detail-Seite
- WorkflowList: Mandant-Vorfilter via URL-Parameter
- WorkflowEditor: Mandant-Vorauswahl bei Erstellung aus Mandant-Kontext
- Arbeitsweise: Kunde öffnen → dessen Workflows sehen/verwalten
This commit is contained in:
root 2026-03-30 07:33:19 +00:00
parent 3bad762329
commit 5ed76f6810
7 changed files with 862 additions and 64 deletions

View File

@ -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() {
}
/>
<Route
path="/tenants/:id"
element={
<ProtectedRoute requiredRoles={['SYSTEM_ADMIN', 'TECHNICIAN']}>
<AppLayout>
<TenantDetail />
</AppLayout>
</ProtectedRoute>
}
/>
<Route
path="/users"
element={

View File

@ -1,5 +1,5 @@
import { Fragment, useState } from 'react';
import { Link, NavLink, useNavigate } from 'react-router-dom';
import { Link, NavLink, useNavigate, useLocation } from 'react-router-dom';
import {
Building2,
FileText,
@ -17,20 +17,10 @@ import { Menu as HeadlessMenu, MenuButton, MenuItem, MenuItems, Transition } fro
import { useAuth } from '../hooks/useAuth';
import { useDarkMode } from '../hooks/useDarkMode';
const navigation = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
{ name: 'Workflows', href: '/workflows', icon: FileText },
{ name: 'Vorlagen', href: '/workflows?template=true', icon: Settings },
];
const adminNavigation = [
{ name: 'Mandanten', href: '/tenants', icon: Building2 },
{ name: 'Benutzer', href: '/users', icon: Users },
];
export default function Layout({ children }: { children: React.ReactNode }) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [sidebarOpen, setSidebarOpen] = useState(false);
const { isDark, toggle: toggleDark } = useDarkMode();
@ -42,6 +32,21 @@ export default function Layout({ children }: { children: React.ReactNode }) {
navigate('/login');
};
// Build navigation based on role
const navigation = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
// Mandanten visible to admins/technicians, placed early in nav
...(isAdmin
? [{ name: 'Mandanten', href: '/tenants', icon: Building2 }]
: []),
{ name: 'Workflows', href: '/workflows', icon: FileText },
{ name: 'Vorlagen', href: '/workflows?template=true', icon: Settings },
];
const adminNavigation = [
{ name: 'Benutzer', href: '/users', icon: Users },
];
const navLinkClass = ({ isActive }: { isActive: boolean }) =>
`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 = (
<>
<div className="flex h-16 items-center gap-3 border-b border-gray-200 px-6 dark:border-gray-700">
@ -66,7 +74,30 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</div>
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-4">
{navigation.map((item) => (
{navigation.map((item) => {
// Special handling for Mandanten: also highlight when on /tenants/:id
if (item.href === '/tenants') {
const isActive =
location.pathname === '/tenants' || isTenantDetailPage;
return (
<NavLink
key={item.href}
to={item.href}
className={() =>
`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
isActive
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: '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'
}`
}
onClick={() => setSidebarOpen(false)}
>
<item.icon size={20} />
{item.name}
</NavLink>
);
}
return (
<NavLink
key={item.href}
to={item.href}
@ -77,7 +108,8 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<item.icon size={20} />
{item.name}
</NavLink>
))}
);
})}
{isAdmin && (
<>

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Archive,
Building2,
Clock,
FileCheck,
FilePlus,
@ -22,29 +23,78 @@ interface Stats {
draft: number;
}
interface TenantWithStats extends Tenant {
workflowTotal: number;
workflowPublished: number;
lastUpdated: string | null;
}
export default function Dashboard() {
const { user } = useAuth();
const [stats, setStats] = useState<Stats>({ total: 0, published: 0, review: 0, draft: 0 });
const [recent, setRecent] = useState<Workflow[]>([]);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [selectedTenant, setSelectedTenant] = useState('');
const [tenantCards, setTenantCards] = useState<TenantWithStats[]>([]);
const [loading, setLoading] = useState(true);
const [tenantsLoading, setTenantsLoading] = useState(true);
const isAdmin = user?.role === 'SYSTEM_ADMIN' || user?.role === 'TECHNICIAN';
const isCustomer = user?.role === 'CUSTOMER_ADMIN' || user?.role === 'CUSTOMER_USER';
// Load tenants for admin/technician view
useEffect(() => {
if (!isAdmin) {
setTenantsLoading(false);
return;
}
setTenantsLoading(true);
tenantsApi
.list({ pageSize: 100 })
.then(async (r) => {
setTenants(r.data);
// Load workflow stats for each tenant
const cardsWithStats: TenantWithStats[] = await Promise.all(
r.data.map(async (tenant) => {
try {
const [allRes, pubRes] = await Promise.all([
workflowsApi.list({ tenantId: tenant.id, pageSize: 1 }),
workflowsApi.list({ tenantId: tenant.id, pageSize: 1, status: 'PUBLISHED' }),
]);
// Get latest workflow date
const latestRes = await workflowsApi.list({ tenantId: tenant.id, pageSize: 1 });
const lastUpdated = latestRes.data.length > 0 ? latestRes.data[0].updatedAt : null;
return {
...tenant,
workflowTotal: allRes.total,
workflowPublished: pubRes.total,
lastUpdated,
};
} catch {
return {
...tenant,
workflowTotal: 0,
workflowPublished: 0,
lastUpdated: null,
};
}
})
);
setTenantCards(cardsWithStats);
})
.catch(() => {})
.finally(() => setTenantsLoading(false));
}, [isAdmin]);
// Load stats and recent workflows
useEffect(() => {
loadData();
if (isAdmin) {
tenantsApi.list({ pageSize: 100 }).then((r) => setTenants(r.data)).catch(() => {});
}
}, [isAdmin]);
}, []);
const loadData = async () => {
setLoading(true);
try {
const params: Record<string, unknown> = { page: 1, pageSize: 5 };
if (selectedTenant) params.tenantId = selectedTenant;
// For customer users, stats are scoped to their tenant automatically by backend
const [allRes, pubRes, revRes, draftRes] = await Promise.all([
workflowsApi.list({ ...params, pageSize: 1 } as Parameters<typeof workflowsApi.list>[0]),
workflowsApi.list({ ...params, pageSize: 1, status: 'PUBLISHED' } as Parameters<typeof workflowsApi.list>[0]),
@ -68,10 +118,166 @@ export default function Dashboard() {
}
};
useEffect(() => {
loadData();
}, [selectedTenant]);
// --- Admin / Technician View ---
if (isAdmin) {
return (
<div className="space-y-8">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Willkommen, {user?.firstName}!
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Hier ist eine Übersicht Ihrer Mandanten und Workflows.
</p>
</div>
<Link to="/tenants" className="btn-secondary gap-2">
<Plus size={18} />
Neuer Mandant
</Link>
</div>
{/* Tenant Cards Grid */}
<div>
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
Mandanten
</h2>
{tenantsLoading ? (
<div className="flex items-center justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary-600 border-t-transparent" />
</div>
) : tenantCards.length === 0 ? (
<div className="card py-12 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-700">
<Building2 className="text-gray-400 dark:text-gray-500" size={32} />
</div>
<h3 className="mt-4 text-base font-semibold text-gray-900 dark:text-white">
Keine Mandanten vorhanden
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Erstellen Sie den ersten Mandanten.
</p>
<div className="mt-6">
<Link to="/tenants" className="btn-primary gap-2">
<Plus size={18} />
Neuer Mandant
</Link>
</div>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{tenantCards.map((tc) => (
<Link
key={tc.id}
to={`/tenants/${tc.id}`}
className="card p-5 transition-shadow hover:shadow-md"
>
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-50 text-lg font-bold text-primary-700 dark:bg-primary-900/30 dark:text-primary-400">
{tc.name[0]}
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate text-base font-semibold text-gray-900 dark:text-white">
{tc.name}
</h3>
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{tc.slug}
</p>
</div>
{!tc.active && (
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:ring-gray-600">
Inaktiv
</span>
)}
</div>
<div className="mt-4 flex items-center gap-3">
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary-50 px-2.5 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-400">
<FileText size={12} />
{tc.workflowPublished}/{tc.workflowTotal} Workflows
</span>
</div>
<div className="mt-3 flex items-center justify-between text-xs text-gray-400 dark:text-gray-500">
<span>
{tc.lastUpdated
? `Zuletzt aktiv: ${new Date(tc.lastUpdated).toLocaleDateString('de-DE')}`
: 'Keine Workflows'}
</span>
<span className="font-medium text-primary-600 dark:text-primary-400">
Workflows anzeigen
</span>
</div>
</Link>
))}
</div>
)}
</div>
{/* Recent Activity across all tenants */}
<div className="card">
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
Letzte Aktivität
</h2>
<Link
to="/workflows"
className="text-sm font-medium text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
Alle Workflows
</Link>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary-600 border-t-transparent" />
</div>
) : recent.length === 0 ? (
<div className="py-12 text-center text-sm text-gray-500 dark:text-gray-400">
Noch keine Workflows vorhanden.
</div>
) : (
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
{recent.map((wf) => (
<li key={wf.id}>
<Link
to={`/workflows/${wf.id}`}
className="flex items-center gap-4 px-6 py-4 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 dark:bg-gray-700">
<FilePlus className="text-gray-500 dark:text-gray-400" size={20} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-gray-900 dark:text-white">
{wf.title}
</p>
<p className="truncate text-sm text-gray-500 dark:text-gray-400">
{wf.tenant && (
<Link
to={`/tenants/${wf.tenantId}`}
className="font-medium text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
onClick={(e) => e.stopPropagation()}
>
{wf.tenant.name}
</Link>
)}
{wf.tenant && <span> &middot; </span>}
{wf._count?.steps ?? wf.steps?.length ?? 0} Schritte &middot; v{wf.version}
</p>
</div>
<StatusBadge status={wf.status} />
<span className="hidden text-xs text-gray-400 dark:text-gray-500 sm:inline">
{new Date(wf.updatedAt).toLocaleDateString('de-DE')}
</span>
</Link>
</li>
))}
</ul>
)}
</div>
</div>
);
}
// --- Customer View (CUSTOMER_ADMIN / CUSTOMER_USER) ---
const statCards = [
{
label: 'Gesamt',
@ -108,23 +314,11 @@ export default function Dashboard() {
Willkommen, {user?.firstName}!
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Hier ist eine Übersicht Ihrer Workflows.
{user?.tenant
? `Übersicht für ${user.tenant.name}`
: 'Hier ist eine Übersicht Ihrer Workflows.'}
</p>
</div>
{isAdmin && tenants.length > 0 && (
<select
value={selectedTenant}
onChange={(e) => setSelectedTenant(e.target.value)}
className="input-field w-auto"
>
<option value="">Alle Mandanten</option>
{tenants.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
)}
</div>
{/* Stats */}
@ -217,10 +411,6 @@ export default function Dashboard() {
{wf.title}
</p>
<p className="truncate text-sm text-gray-500 dark:text-gray-400">
{wf.tenant && (
<span className="font-medium text-primary-600 dark:text-primary-400">{wf.tenant.name}</span>
)}
{wf.tenant && <span> &middot; </span>}
{wf._count?.steps ?? wf.steps?.length ?? 0} Schritte &middot; v{wf.version}
</p>
</div>

View File

@ -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<Tenant | null>(null);
const [stats, setStats] = useState<TenantStats>({ total: 0, published: 0, draft: 0, review: 0 });
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [recentActivity, setRecentActivity] = useState<Workflow[]>([]);
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<ViewMode>('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 (
<div className="flex items-center justify-center py-24">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-primary-600 border-t-transparent" />
</div>
);
}
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 (
<div className="space-y-8">
{/* Back link */}
<button
onClick={() => navigate('/tenants')}
className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
<ArrowLeft size={16} />
Alle Mandanten
</button>
{/* Tenant Header */}
<div className="card p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-primary-50 text-xl font-bold text-primary-700 dark:bg-primary-900/30 dark:text-primary-400">
{tenant.name[0]}
</div>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
{tenant.name}
</h1>
{tenant.active ? (
<span className="inline-flex items-center gap-1 rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-300 dark:bg-green-900/30 dark:text-green-400 dark:ring-green-700">
<Check size={12} />
Aktiv
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:ring-gray-600">
<X size={12} />
Inaktiv
</span>
)}
</div>
<p className="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
Slug: {tenant.slug}
</p>
</div>
</div>
<div className="flex gap-2">
<Link
to={`/workflows/new?tenantId=${tenant.id}`}
className="btn-primary gap-2"
>
<Plus size={18} />
Neuer Workflow
</Link>
</div>
</div>
</div>
{/* Stats Row */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{statCards.map((card) => (
<div key={card.label} className="card p-5">
<div className="flex items-center justify-between">
<div
className={`flex h-10 w-10 items-center justify-center rounded-lg ${card.color}`}
>
<card.icon size={20} />
</div>
</div>
<div className="mt-3">
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{card.value}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{card.label}</p>
</div>
</div>
))}
</div>
{/* Workflows Section */}
<div className="space-y-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Workflows
</h2>
<Link
to={`/workflows?tenantId=${tenant.id}`}
className="text-sm font-medium text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
Alle in Workflow-Ansicht
</Link>
</div>
{/* Filters */}
<div className="card p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="flex-1">
<SearchBar
value={search}
onChange={(val) => {
setSearch(val);
setPage(1);
}}
placeholder="Workflows suchen..."
/>
</div>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(1);
}}
className="input-field w-auto"
>
{statusOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<div className="flex rounded-lg border border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-800">
<button
onClick={() => setViewMode('grid')}
className={`rounded-l-lg p-2 ${
viewMode === 'grid'
? 'bg-primary-50 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
>
<Grid3X3 size={18} />
</button>
<button
onClick={() => setViewMode('list')}
className={`rounded-r-lg p-2 ${
viewMode === 'list'
? 'bg-primary-50 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
>
<List size={18} />
</button>
</div>
</div>
</div>
{/* Results */}
{workflowsLoading ? (
<div className="flex items-center justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary-600 border-t-transparent" />
</div>
) : workflows.length === 0 ? (
<EmptyState
icon={FileText}
title="Keine Workflows gefunden"
description={
search || statusFilter
? 'Versuchen Sie andere Suchkriterien.'
: 'Erstellen Sie den ersten Workflow für diesen Mandanten.'
}
action={
<Link to={`/workflows/new?tenantId=${tenant.id}`} className="btn-primary gap-2">
<Plus size={18} />
Neuer Workflow
</Link>
}
/>
) : viewMode === 'grid' ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{workflows.map((wf) => (
<Link
key={wf.id}
to={`/workflows/${wf.id}`}
className="card p-5 transition-shadow hover:shadow-md"
>
<div className="flex items-start justify-between gap-2">
<h3 className="line-clamp-1 font-semibold text-gray-900 dark:text-white">
{wf.title}
</h3>
<StatusBadge status={wf.status} />
</div>
{wf.description && (
<p className="mt-2 line-clamp-2 text-sm text-gray-500 dark:text-gray-400">
{wf.description}
</p>
)}
<div className="mt-4 flex items-center justify-between text-xs text-gray-400 dark:text-gray-500">
<span>
{wf._count?.steps ?? wf.steps?.length ?? 0} Schritte &middot; v{wf.version}
</span>
<span>
{new Date(wf.updatedAt).toLocaleDateString('de-DE')}
</span>
</div>
{wf.category && (
<div className="mt-3 flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<Tag size={12} />
{wf.category.name}
</div>
)}
{wf.tags && wf.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{wf.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300"
>
{tag}
</span>
))}
{wf.tags.length > 3 && (
<span className="text-xs text-gray-400 dark:text-gray-500">
+{wf.tags.length - 3}
</span>
)}
</div>
)}
</Link>
))}
</div>
) : (
<div className="card divide-y divide-gray-100 dark:divide-gray-700">
{workflows.map((wf) => (
<Link
key={wf.id}
to={`/workflows/${wf.id}`}
className="flex items-center gap-4 px-6 py-4 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 dark:bg-gray-700">
<FileText className="text-gray-500 dark:text-gray-400" size={20} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-gray-900 dark:text-white">{wf.title}</p>
<p className="truncate text-sm text-gray-500 dark:text-gray-400">
{wf.description}
</p>
</div>
{wf.category && (
<span className="hidden rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 md:inline dark:bg-gray-700 dark:text-gray-300">
{wf.category.name}
</span>
)}
<span className="hidden text-sm text-gray-400 dark:text-gray-500 sm:inline">
{wf._count?.steps ?? wf.steps?.length ?? 0} Schritte
</span>
<StatusBadge status={wf.status} />
<span className="hidden text-xs text-gray-400 dark:text-gray-500 lg:inline">
{new Date(wf.updatedAt).toLocaleDateString('de-DE')}
</span>
</Link>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
)}
</div>
{/* Recent Activity */}
<div className="card">
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
Letzte Aktivität
</h2>
</div>
{recentActivity.length === 0 ? (
<div className="py-12 text-center text-sm text-gray-500 dark:text-gray-400">
Noch keine Aktivität vorhanden.
</div>
) : (
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
{recentActivity.map((wf) => (
<li key={wf.id}>
<Link
to={`/workflows/${wf.id}`}
className="flex items-center gap-4 px-6 py-4 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 dark:bg-gray-700">
<FilePlus className="text-gray-500 dark:text-gray-400" size={20} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-gray-900 dark:text-white">
{wf.title}
</p>
<p className="truncate text-sm text-gray-500 dark:text-gray-400">
{wf._count?.steps ?? wf.steps?.length ?? 0} Schritte &middot; v{wf.version}
</p>
</div>
<StatusBadge status={wf.status} />
<span className="hidden text-xs text-gray-400 dark:text-gray-500 sm:inline">
{new Date(wf.updatedAt).toLocaleDateString('de-DE')}
</span>
</Link>
</li>
))}
</ul>
)}
</div>
</div>
);
}

View File

@ -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<Tenant[]>([]);
const navigate = useNavigate();
const [tenants, setTenants] = useState<TenantWithMeta[]>([]);
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 (
<div className="space-y-6">
{/* Header */}
@ -181,6 +213,12 @@ export default function TenantList() {
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Slug
</th>
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Workflows
</th>
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Zuletzt aktiv
</th>
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Status
</th>
@ -191,7 +229,11 @@ export default function TenantList() {
</thead>
<tbody className="divide-y divide-gray-100 bg-white dark:divide-gray-700 dark:bg-gray-800">
{tenants.map((tenant) => (
<tr key={tenant.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<tr
key={tenant.id}
onClick={() => handleRowClick(tenant.id)}
className="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
>
<td className="whitespace-nowrap px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary-50 text-sm font-bold text-primary-700 dark:bg-primary-900/30 dark:text-primary-400">
@ -205,6 +247,16 @@ export default function TenantList() {
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{tenant.slug}
</td>
<td className="whitespace-nowrap px-6 py-4">
<span className="inline-flex items-center rounded-full bg-primary-50 px-2.5 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-400">
{tenant.workflowCount}
</span>
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{tenant.lastActive
? new Date(tenant.lastActive).toLocaleDateString('de-DE')
: '-'}
</td>
<td className="whitespace-nowrap px-6 py-4">
{tenant.active ? (
<span className="inline-flex items-center gap-1 rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-300 dark:bg-green-900/30 dark:text-green-400 dark:ring-green-700">
@ -221,14 +273,17 @@ export default function TenantList() {
<td className="whitespace-nowrap px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => openEdit(tenant)}
onClick={(e) => openEdit(tenant, e)}
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
title="Bearbeiten"
>
<Edit size={16} />
</button>
<button
onClick={() => setDeactivateTarget(tenant)}
onClick={(e) => {
e.stopPropagation();
setDeactivateTarget(tenant);
}}
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
title={tenant.active ? 'Deaktivieren' : 'Aktivieren'}
>

View File

@ -1,5 +1,5 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
ArrowLeft,
GripVertical,
@ -40,16 +40,20 @@ function nextKey() {
export default function WorkflowEditor() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { user } = useAuth();
const isNew = !id;
// Pre-select tenant from URL query param (when coming from tenant detail page)
const urlTenantId = searchParams.get('tenantId') || '';
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [categoryId, setCategoryId] = useState('');
const [tagsInput, setTagsInput] = useState('');
const [isTemplate, setIsTemplate] = useState(false);
const [tenantId, setTenantId] = useState(user?.tenantId || '');
const [tenantId, setTenantId] = useState(urlTenantId || user?.tenantId || '');
const [tenants, setTenants] = useState<Tenant[]>([]);
const [steps, setSteps] = useState<StepDraft[]>([]);
const [categories, setCategories] = useState<Category[]>([]);

View File

@ -7,6 +7,7 @@ import {
List,
Plus,
Tag,
X,
} from 'lucide-react';
import toast from 'react-hot-toast';
import { workflowsApi, categoriesApi, tenantsApi } from '../services/api';
@ -30,6 +31,7 @@ const statusOptions: { value: string; label: string }[] = [
export default function WorkflowList() {
const [searchParams, setSearchParams] = useSearchParams();
const isTemplate = searchParams.get('template') === 'true';
const urlTenantId = searchParams.get('tenantId') || '';
const { user } = useAuth();
const isAdmin = user?.role === 'SYSTEM_ADMIN' || user?.role === 'TECHNICIAN';
@ -39,12 +41,21 @@ export default function WorkflowList() {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
const [tenantFilter, setTenantFilter] = useState('');
const [tenantFilter, setTenantFilter] = useState(urlTenantId);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(true);
const [viewMode, setViewMode] = useState<ViewMode>('grid');
// Sync tenantFilter from URL param
useEffect(() => {
const paramTenantId = searchParams.get('tenantId') || '';
if (paramTenantId !== tenantFilter) {
setTenantFilter(paramTenantId);
setPage(1);
}
}, [searchParams]);
useEffect(() => {
categoriesApi.list().then(setCategories).catch(() => {});
if (isAdmin) {
@ -78,6 +89,18 @@ export default function WorkflowList() {
}
};
// Find the current tenant name for the breadcrumb chip
const activeTenant = tenants.find((t) => t.id === tenantFilter);
const clearTenantFilter = () => {
setTenantFilter('');
setPage(1);
// Remove tenantId from URL params
const newParams = new URLSearchParams(searchParams);
newParams.delete('tenantId');
setSearchParams(newParams);
};
return (
<div className="space-y-6">
{/* Header */}
@ -92,12 +115,36 @@ export default function WorkflowList() {
: 'Alle Workflows Ihres Mandanten'}
</p>
</div>
<Link to="/workflows/new" className="btn-primary gap-2">
<Link
to={tenantFilter ? `/workflows/new?tenantId=${tenantFilter}` : '/workflows/new'}
className="btn-primary gap-2"
>
<Plus size={18} />
Neuer Workflow
</Link>
</div>
{/* Tenant breadcrumb chip when filtered */}
{isAdmin && activeTenant && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">Mandant:</span>
<Link
to={`/tenants/${activeTenant.id}`}
className="inline-flex items-center gap-1.5 rounded-full bg-primary-50 px-3 py-1 text-sm font-medium text-primary-700 transition-colors hover:bg-primary-100 dark:bg-primary-900/30 dark:text-primary-400 dark:hover:bg-primary-900/50"
>
<Building2 size={14} />
{activeTenant.name}
</Link>
<button
onClick={clearTenantFilter}
className="rounded-full p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
title="Filter entfernen"
>
<X size={16} />
</button>
</div>
)}
{/* Filters */}
<div className="card p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">