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:
parent
3bad762329
commit
5ed76f6810
@ -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={
|
||||
|
||||
@ -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,18 +74,42 @@ 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) => (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
end={item.href === '/'}
|
||||
className={navLinkClass}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
{item.name}
|
||||
</NavLink>
|
||||
))}
|
||||
{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}
|
||||
end={item.href === '/'}
|
||||
className={navLinkClass}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
{item.name}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
|
||||
@ -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> · </span>}
|
||||
{wf._count?.steps ?? wf.steps?.length ?? 0} Schritte · 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> · </span>}
|
||||
{wf._count?.steps ?? wf.steps?.length ?? 0} Schritte · v{wf.version}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
458
frontend/src/pages/TenantDetail.tsx
Normal file
458
frontend/src/pages/TenantDetail.tsx
Normal 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 · 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 · 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>
|
||||
);
|
||||
}
|
||||
@ -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'}
|
||||
>
|
||||
|
||||
@ -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[]>([]);
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user