feat: initial workflow portal - multi-tenant architecture

- Backend: Node.js + Express + TypeScript + Prisma + PostgreSQL
- Frontend: React + TypeScript + Tailwind CSS + Vite
- Multi-tenant with role-based access (SystemAdmin, Technician, Customer)
- Workflow editor with drag-and-drop steps
- JWT authentication with refresh tokens
- German UI throughout
This commit is contained in:
root
2026-03-20 22:48:04 +00:00
commit d58d53fa3b
48 changed files with 5688 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Workflow Portal</title>
</head>
<body class="bg-gray-50 text-gray-900 antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
{
"name": "workflow-portal-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@headlessui/react": "^2.2.0",
"@hello-pangea/dnd": "^17.0.0",
"axios": "^1.7.9",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"react-router-dom": "^7.1.1"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vite": "^6.0.7"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#2563eb"/><text x="16" y="22" text-anchor="middle" font-family="system-ui" font-weight="bold" font-size="16" fill="white">W</text></svg>

After

Width:  |  Height:  |  Size: 261 B

+99
View File
@@ -0,0 +1,99 @@
import { Route, Routes } from 'react-router-dom';
import { ProtectedRoute } from './hooks/useAuth';
import Layout from './components/Layout';
import LoginPage from './pages/LoginPage';
import Dashboard from './pages/Dashboard';
import WorkflowList from './pages/WorkflowList';
import WorkflowView from './pages/WorkflowView';
import WorkflowEditor from './pages/WorkflowEditor';
import TenantList from './pages/TenantList';
import UserList from './pages/UserList';
function AppLayout({ children }: { children: React.ReactNode }) {
return <Layout>{children}</Layout>;
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<ProtectedRoute>
<AppLayout>
<Dashboard />
</AppLayout>
</ProtectedRoute>
}
/>
<Route
path="/workflows"
element={
<ProtectedRoute>
<AppLayout>
<WorkflowList />
</AppLayout>
</ProtectedRoute>
}
/>
<Route
path="/workflows/new"
element={
<ProtectedRoute>
<AppLayout>
<WorkflowEditor />
</AppLayout>
</ProtectedRoute>
}
/>
<Route
path="/workflows/:id"
element={
<ProtectedRoute>
<AppLayout>
<WorkflowView />
</AppLayout>
</ProtectedRoute>
}
/>
<Route
path="/workflows/:id/edit"
element={
<ProtectedRoute>
<AppLayout>
<WorkflowEditor />
</AppLayout>
</ProtectedRoute>
}
/>
<Route
path="/tenants"
element={
<ProtectedRoute requiredRoles={['SYSTEM_ADMIN', 'TECHNICIAN']}>
<AppLayout>
<TenantList />
</AppLayout>
</ProtectedRoute>
}
/>
<Route
path="/users"
element={
<ProtectedRoute requiredRoles={['SYSTEM_ADMIN', 'TECHNICIAN']}>
<AppLayout>
<UserList />
</AppLayout>
</ProtectedRoute>
}
/>
</Routes>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { AlertTriangle } from 'lucide-react';
import Modal from './Modal';
interface ConfirmDialogProps {
open: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: 'danger' | 'warning';
loading?: boolean;
}
export default function ConfirmDialog({
open,
onClose,
onConfirm,
title,
message,
confirmLabel = 'Bestätigen',
cancelLabel = 'Abbrechen',
variant = 'danger',
loading = false,
}: ConfirmDialogProps) {
return (
<Modal open={open} onClose={onClose} size="sm">
<div className="flex flex-col items-center text-center">
<div
className={`flex h-12 w-12 items-center justify-center rounded-full ${
variant === 'danger' ? 'bg-red-100' : 'bg-amber-100'
}`}
>
<AlertTriangle
className={variant === 'danger' ? 'text-red-600' : 'text-amber-600'}
size={24}
/>
</div>
<h3 className="mt-3 text-lg font-semibold text-gray-900">{title}</h3>
<p className="mt-2 text-sm text-gray-500">{message}</p>
<div className="mt-6 flex w-full gap-3">
<button
onClick={onClose}
disabled={loading}
className="btn-secondary flex-1"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
disabled={loading}
className={`flex-1 ${variant === 'danger' ? 'btn-danger' : 'btn-primary'}`}
>
{loading ? 'Bitte warten...' : confirmLabel}
</button>
</div>
</div>
</Modal>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { Inbox } from 'lucide-react';
interface EmptyStateProps {
icon?: React.ElementType;
title: string;
description?: string;
action?: React.ReactNode;
}
export default function EmptyState({
icon: Icon = Inbox,
title,
description,
action,
}: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-gray-100">
<Icon className="text-gray-400" size={32} />
</div>
<h3 className="mt-4 text-base font-semibold text-gray-900">{title}</h3>
{description && (
<p className="mt-1 text-sm text-gray-500">{description}</p>
)}
{action && <div className="mt-6">{action}</div>}
</div>
);
}
+212
View File
@@ -0,0 +1,212 @@
import { Fragment, useState } from 'react';
import { Link, NavLink, useNavigate } from 'react-router-dom';
import {
Building2,
FileText,
LayoutDashboard,
LogOut,
Menu,
Settings,
User,
Users,
X,
} from 'lucide-react';
import { Menu as HeadlessMenu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
import { useAuth } from '../hooks/useAuth';
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 [sidebarOpen, setSidebarOpen] = useState(false);
const isAdmin =
user?.role === 'SYSTEM_ADMIN' || user?.role === 'TECHNICIAN';
const handleLogout = async () => {
await logout();
navigate('/login');
};
const navLinkClass = ({ isActive }: { isActive: boolean }) =>
`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'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
}`;
const sidebarContent = (
<>
<div className="flex h-16 items-center gap-3 border-b border-gray-200 px-6">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary-600 text-sm font-bold text-white">
WP
</div>
<div>
<h1 className="text-sm font-semibold text-gray-900">
Workflow Portal
</h1>
{user?.tenant && (
<p className="text-xs text-gray-500">{user.tenant.name}</p>
)}
</div>
</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>
))}
{isAdmin && (
<>
<div className="my-4 border-t border-gray-200" />
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-gray-400">
Administration
</p>
{adminNavigation.map((item) => (
<NavLink
key={item.href}
to={item.href}
className={navLinkClass}
onClick={() => setSidebarOpen(false)}
>
<item.icon size={20} />
{item.name}
</NavLink>
))}
</>
)}
</nav>
<div className="border-t border-gray-200 p-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary-100 text-sm font-semibold text-primary-700">
{user?.firstName?.[0]}
{user?.lastName?.[0]}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-gray-900">
{user?.firstName} {user?.lastName}
</p>
<p className="truncate text-xs text-gray-500">{user?.email}</p>
</div>
</div>
</div>
</>
);
return (
<div className="flex h-screen overflow-hidden bg-gray-50">
{/* Mobile sidebar overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/30 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Mobile sidebar */}
<aside
className={`fixed inset-y-0 left-0 z-50 flex w-64 flex-col bg-white shadow-xl transition-transform duration-200 lg:hidden ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<button
onClick={() => setSidebarOpen(false)}
className="absolute right-3 top-4 rounded-lg p-1 text-gray-400 hover:bg-gray-100"
>
<X size={20} />
</button>
{sidebarContent}
</aside>
{/* Desktop sidebar */}
<aside className="hidden w-64 flex-col border-r border-gray-200 bg-white lg:flex">
{sidebarContent}
</aside>
{/* Main content */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* Top bar */}
<header className="flex h-16 items-center justify-between border-b border-gray-200 bg-white px-4 lg:px-8">
<button
onClick={() => setSidebarOpen(true)}
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 lg:hidden"
>
<Menu size={22} />
</button>
<div className="flex-1" />
<HeadlessMenu as="div" className="relative">
<MenuButton className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 hover:bg-gray-100">
<User size={18} />
<span className="hidden sm:inline">
{user?.firstName} {user?.lastName}
</span>
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<MenuItems className="absolute right-0 mt-2 w-48 origin-top-right rounded-lg border border-gray-200 bg-white py-1 shadow-lg focus:outline-none">
<MenuItem>
{({ focus }) => (
<Link
to="/"
className={`flex items-center gap-2 px-4 py-2 text-sm ${
focus ? 'bg-gray-50 text-gray-900' : 'text-gray-700'
}`}
>
<User size={16} />
Profil
</Link>
)}
</MenuItem>
<MenuItem>
{({ focus }) => (
<button
onClick={handleLogout}
className={`flex w-full items-center gap-2 px-4 py-2 text-sm ${
focus ? 'bg-gray-50 text-gray-900' : 'text-gray-700'
}`}
>
<LogOut size={16} />
Abmelden
</button>
)}
</MenuItem>
</MenuItems>
</Transition>
</HeadlessMenu>
</header>
{/* Page content */}
<main className="flex-1 overflow-y-auto p-4 lg:p-8">{children}</main>
</div>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
import { Fragment } from 'react';
import { X } from 'lucide-react';
interface ModalProps {
open: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl';
}
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
};
export default function Modal({
open,
onClose,
title,
children,
size = 'md',
}: ModalProps) {
return (
<Transition show={open} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<TransitionChild
as={Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<TransitionChild
as={Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-150"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel
className={`w-full ${sizeClasses[size]} transform rounded-xl bg-white p-6 shadow-xl transition-all`}
>
<div className="flex items-center justify-between">
{title && (
<DialogTitle className="text-lg font-semibold text-gray-900">
{title}
</DialogTitle>
)}
<button
onClick={onClose}
className="ml-auto rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
>
<X size={20} />
</button>
</div>
<div className="mt-4">{children}</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
interface PaginationProps {
page: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export default function Pagination({
page,
totalPages,
onPageChange,
}: PaginationProps) {
if (totalPages <= 1) return null;
const pages: (number | '...')[] = [];
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= page - 1 && i <= page + 1)) {
pages.push(i);
} else if (pages[pages.length - 1] !== '...') {
pages.push('...');
}
}
return (
<nav className="flex items-center justify-center gap-1">
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronLeft size={18} />
</button>
{pages.map((p, idx) =>
p === '...' ? (
<span key={`dots-${idx}`} className="px-2 text-gray-400">
...
</span>
) : (
<button
key={p}
onClick={() => onPageChange(p)}
className={`min-w-[36px] rounded-lg px-3 py-1.5 text-sm font-medium ${
p === page
? 'bg-primary-600 text-white'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
{p}
</button>
)
)}
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronRight size={18} />
</button>
</nav>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { Search, X } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
interface SearchBarProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
debounceMs?: number;
}
export default function SearchBar({
value,
onChange,
placeholder = 'Suchen...',
debounceMs = 300,
}: SearchBarProps) {
const [internal, setInternal] = useState(value);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
setInternal(value);
}, [value]);
const handleChange = useCallback(
(val: string) => {
setInternal(val);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => onChange(val), debounceMs);
},
[onChange, debounceMs]
);
useEffect(() => () => clearTimeout(timerRef.current), []);
return (
<div className="relative">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
size={18}
/>
<input
type="text"
value={internal}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
className="input-field pl-10 pr-9"
/>
{internal && (
<button
onClick={() => handleChange('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<X size={16} />
</button>
)}
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { Workflow } from '../types';
const statusConfig: Record<
Workflow['status'],
{ label: string; className: string }
> = {
DRAFT: {
label: 'Entwurf',
className: 'bg-gray-100 text-gray-700 ring-gray-300',
},
REVIEW: {
label: 'In Prüfung',
className: 'bg-yellow-50 text-yellow-700 ring-yellow-300',
},
PUBLISHED: {
label: 'Veröffentlicht',
className: 'bg-green-50 text-green-700 ring-green-300',
},
ARCHIVED: {
label: 'Archiviert',
className: 'bg-red-50 text-red-700 ring-red-300',
},
};
interface StatusBadgeProps {
status: Workflow['status'];
}
export default function StatusBadge({ status }: StatusBadgeProps) {
const config = statusConfig[status];
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset ${config.className}`}
>
{config.label}
</span>
);
}
+40
View File
@@ -0,0 +1,40 @@
import {
AlertTriangle,
CheckSquare,
FileText,
Info,
Terminal,
} from 'lucide-react';
import type { WorkflowStep } from '../types';
const typeConfig: Record<
WorkflowStep['type'],
{ icon: React.ElementType; label: string; color: string }
> = {
INSTRUCTION: { icon: FileText, label: 'Anleitung', color: 'text-blue-600' },
CHECKLIST: { icon: CheckSquare, label: 'Checkliste', color: 'text-green-600' },
WARNING: { icon: AlertTriangle, label: 'Warnung', color: 'text-amber-600' },
INFO: { icon: Info, label: 'Info', color: 'text-cyan-600' },
COMMAND: { icon: Terminal, label: 'Befehl', color: 'text-purple-600' },
};
interface StepTypeIconProps {
type: WorkflowStep['type'];
showLabel?: boolean;
size?: number;
}
export default function StepTypeIcon({
type,
showLabel = false,
size = 18,
}: StepTypeIconProps) {
const config = typeConfig[type];
const Icon = config.icon;
return (
<span className={`inline-flex items-center gap-1.5 ${config.color}`}>
<Icon size={size} />
{showLabel && <span className="text-xs font-medium">{config.label}</span>}
</span>
);
}
+111
View File
@@ -0,0 +1,111 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import type { User } from '../types';
import { authApi } from '../services/api';
interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const isAuthenticated = !!user;
const loadUser = useCallback(async () => {
const token = localStorage.getItem('accessToken');
if (!token) {
setLoading(false);
return;
}
try {
const userData = await authApi.me();
setUser(userData);
} catch {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
setUser(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadUser();
}, [loadUser]);
const login = useCallback(async (email: string, password: string) => {
const response = await authApi.login(email, password);
localStorage.setItem('accessToken', response.accessToken);
localStorage.setItem('refreshToken', response.refreshToken);
setUser(response.user);
}, []);
const logout = useCallback(async () => {
try {
await authApi.logout();
} catch {
// ignore
}
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
setUser(null);
}, []);
const value = useMemo(
() => ({ user, isAuthenticated, loading, login, logout }),
[user, isAuthenticated, loading, login, logout]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
interface ProtectedRouteProps {
children: React.ReactNode;
requiredRoles?: User['role'][];
}
export function ProtectedRoute({ children, requiredRoles }: ProtectedRouteProps) {
const { isAuthenticated, loading, user } = useAuth();
const location = useLocation();
if (loading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-primary-600 border-t-transparent" />
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (requiredRoles && user && !requiredRoles.includes(user.role)) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
}
+30
View File
@@ -0,0 +1,30 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
@apply scroll-smooth;
}
body {
@apply min-h-screen bg-gray-50 text-gray-900;
}
}
@layer components {
.btn-primary {
@apply inline-flex items-center justify-center rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-secondary {
@apply inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-danger {
@apply inline-flex items-center justify-center rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
}
.input-field {
@apply block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500;
}
.card {
@apply rounded-xl border border-gray-200 bg-white shadow-sm;
}
}
+24
View File
@@ -0,0 +1,24 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { Toaster } from 'react-hot-toast';
import { AuthProvider } from './hooks/useAuth';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: { background: '#1f2937', color: '#f9fafb' },
}}
/>
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
);
+235
View File
@@ -0,0 +1,235 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Archive,
Clock,
FileCheck,
FilePlus,
FileText,
Layers,
Plus,
} from 'lucide-react';
import toast from 'react-hot-toast';
import { useAuth } from '../hooks/useAuth';
import { workflowsApi, tenantsApi } from '../services/api';
import type { Tenant, Workflow } from '../types';
import StatusBadge from '../components/StatusBadge';
interface Stats {
total: number;
published: number;
review: number;
draft: number;
}
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 [loading, setLoading] = useState(true);
const isAdmin = user?.role === 'SYSTEM_ADMIN' || user?.role === 'TECHNICIAN';
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;
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]),
workflowsApi.list({ ...params, pageSize: 1, status: 'REVIEW' } as Parameters<typeof workflowsApi.list>[0]),
workflowsApi.list({ ...params, pageSize: 1, status: 'DRAFT' } as Parameters<typeof workflowsApi.list>[0]),
]);
setStats({
total: allRes.total,
published: pubRes.total,
review: revRes.total,
draft: draftRes.total,
});
const recentRes = await workflowsApi.list(params as Parameters<typeof workflowsApi.list>[0]);
setRecent(recentRes.data);
} catch {
toast.error('Daten konnten nicht geladen werden');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, [selectedTenant]);
const statCards = [
{
label: 'Gesamt',
value: stats.total,
icon: FileText,
color: 'bg-primary-50 text-primary-600',
},
{
label: 'Veröffentlicht',
value: stats.published,
icon: FileCheck,
color: 'bg-green-50 text-green-600',
},
{
label: 'In Prüfung',
value: stats.review,
icon: Clock,
color: 'bg-yellow-50 text-yellow-600',
},
{
label: 'Entwurf',
value: stats.draft,
icon: Archive,
color: 'bg-gray-100 text-gray-600',
},
];
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">
Willkommen, {user?.firstName}!
</h1>
<p className="mt-1 text-sm text-gray-500">
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 */}
<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">
{loading ? '-' : card.value}
</p>
<p className="text-sm text-gray-500">{card.label}</p>
</div>
</div>
))}
</div>
{/* Quick actions */}
<div className="grid gap-4 sm:grid-cols-2">
<Link
to="/workflows/new"
className="card flex items-center gap-4 p-5 transition-shadow hover:shadow-md"
>
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary-50">
<Plus className="text-primary-600" size={24} />
</div>
<div>
<h3 className="font-semibold text-gray-900">Neuer Workflow</h3>
<p className="text-sm text-gray-500">
Erstellen Sie einen neuen Workflow
</p>
</div>
</Link>
<Link
to="/workflows?template=true"
className="card flex items-center gap-4 p-5 transition-shadow hover:shadow-md"
>
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-purple-50">
<Layers className="text-purple-600" size={24} />
</div>
<div>
<h3 className="font-semibold text-gray-900">Vorlagen durchsuchen</h3>
<p className="text-sm text-gray-500">
Starten Sie mit einer bestehenden Vorlage
</p>
</div>
</Link>
</div>
{/* Recent workflows */}
<div className="card">
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<h2 className="text-base font-semibold text-gray-900">
Aktuelle Workflows
</h2>
<Link
to="/workflows"
className="text-sm font-medium text-primary-600 hover:text-primary-700"
>
Alle anzeigen
</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">
Noch keine Workflows vorhanden.
</div>
) : (
<ul className="divide-y divide-gray-100">
{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"
>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
<FilePlus className="text-gray-500" size={20} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-gray-900">
{wf.title}
</p>
<p className="truncate text-sm text-gray-500">
{wf.steps?.length || 0} Schritte &middot; v{wf.version}
</p>
</div>
<StatusBadge status={wf.status} />
<span className="hidden text-xs text-gray-400 sm:inline">
{new Date(wf.updatedAt).toLocaleDateString('de-DE')}
</span>
</Link>
</li>
))}
</ul>
)}
</div>
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Eye, EyeOff, LogIn } from 'lucide-react';
import toast from 'react-hot-toast';
import { useAuth } from '../hooks/useAuth';
export default function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/';
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(email, password);
toast.success('Erfolgreich angemeldet');
navigate(from, { replace: true });
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || 'Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.';
setError(message);
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen">
{/* Left branding panel */}
<div className="hidden flex-1 flex-col justify-center bg-primary-600 px-12 lg:flex">
<div className="max-w-md">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-white/20 text-xl font-bold text-white">
WP
</div>
<h1 className="text-2xl font-bold text-white">Workflow Portal</h1>
</div>
<p className="mt-6 text-lg text-primary-100">
Verwalten Sie Ihre Workflows, Dokumentationen und Anleitungen an
einem zentralen Ort. Effizient, strukturiert und teamübergreifend.
</p>
<div className="mt-10 space-y-4">
{[
'Workflows erstellen und veröffentlichen',
'Schritt-für-Schritt Anleitungen',
'Multi-Mandanten Unterstützung',
'Vorlagen für wiederkehrende Prozesse',
].map((feature) => (
<div key={feature} className="flex items-center gap-3 text-primary-100">
<div className="h-1.5 w-1.5 rounded-full bg-primary-300" />
{feature}
</div>
))}
</div>
</div>
</div>
{/* Right login form */}
<div className="flex flex-1 items-center justify-center px-6 py-12">
<div className="w-full max-w-sm">
<div className="mb-8 text-center lg:hidden">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-primary-600 text-xl font-bold text-white">
WP
</div>
<h1 className="mt-3 text-xl font-bold text-gray-900">
Workflow Portal
</h1>
</div>
<h2 className="text-2xl font-bold text-gray-900">Anmelden</h2>
<p className="mt-2 text-sm text-gray-500">
Melden Sie sich mit Ihren Zugangsdaten an.
</p>
{error && (
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="mt-6 space-y-5">
<div>
<label
htmlFor="email"
className="mb-1.5 block text-sm font-medium text-gray-700"
>
E-Mail-Adresse
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
placeholder="name@firma.de"
className="input-field"
/>
</div>
<div>
<label
htmlFor="password"
className="mb-1.5 block text-sm font-medium text-gray-700"
>
Passwort
</label>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
placeholder="Passwort eingeben"
className="input-field pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
<button
type="submit"
disabled={loading || !email || !password}
className="btn-primary w-full gap-2"
>
{loading ? (
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent" />
) : (
<LogIn size={18} />
)}
{loading ? 'Anmeldung läuft...' : 'Anmelden'}
</button>
</form>
</div>
</div>
</div>
);
}
+340
View File
@@ -0,0 +1,340 @@
import { useEffect, useState } from 'react';
import {
Building2,
Check,
Edit,
Plus,
Power,
X,
} from 'lucide-react';
import toast from 'react-hot-toast';
import { tenantsApi } from '../services/api';
import type { Tenant } from '../types';
import Modal from '../components/Modal';
import ConfirmDialog from '../components/ConfirmDialog';
import SearchBar from '../components/SearchBar';
import Pagination from '../components/Pagination';
import EmptyState from '../components/EmptyState';
export default function TenantList() {
const [tenants, setTenants] = useState<Tenant[]>([]);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(true);
// Modal state
const [modalOpen, setModalOpen] = useState(false);
const [editingTenant, setEditingTenant] = useState<Tenant | null>(null);
const [formName, setFormName] = useState('');
const [formSlug, setFormSlug] = useState('');
const [formActive, setFormActive] = useState(true);
const [formSaving, setFormSaving] = useState(false);
// Deactivate dialog
const [deactivateTarget, setDeactivateTarget] = useState<Tenant | null>(null);
const [deactivating, setDeactivating] = useState(false);
useEffect(() => {
loadTenants();
}, [search, page]);
const loadTenants = async () => {
setLoading(true);
try {
const res = await tenantsApi.list({
page,
pageSize: 20,
search: search || undefined,
});
setTenants(res.data);
setTotalPages(res.totalPages);
} catch {
toast.error('Mandanten konnten nicht geladen werden');
} finally {
setLoading(false);
}
};
const openCreate = () => {
setEditingTenant(null);
setFormName('');
setFormSlug('');
setFormActive(true);
setModalOpen(true);
};
const openEdit = (tenant: Tenant) => {
setEditingTenant(tenant);
setFormName(tenant.name);
setFormSlug(tenant.slug);
setFormActive(tenant.active);
setModalOpen(true);
};
const handleSave = async () => {
if (!formName.trim() || !formSlug.trim()) {
toast.error('Name und Slug sind erforderlich');
return;
}
setFormSaving(true);
try {
if (editingTenant) {
await tenantsApi.update(editingTenant.id, {
name: formName,
slug: formSlug,
active: formActive,
});
toast.success('Mandant aktualisiert');
} else {
await tenantsApi.create({
name: formName,
slug: formSlug,
active: formActive,
});
toast.success('Mandant erstellt');
}
setModalOpen(false);
loadTenants();
} catch {
toast.error('Speichern fehlgeschlagen');
} finally {
setFormSaving(false);
}
};
const handleDeactivate = async () => {
if (!deactivateTarget) return;
setDeactivating(true);
try {
await tenantsApi.update(deactivateTarget.id, {
active: !deactivateTarget.active,
});
toast.success(
deactivateTarget.active
? 'Mandant deaktiviert'
: 'Mandant aktiviert'
);
setDeactivateTarget(null);
loadTenants();
} catch {
toast.error('Aktion fehlgeschlagen');
} finally {
setDeactivating(false);
}
};
return (
<div className="space-y-6">
{/* 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">Mandanten</h1>
<p className="mt-1 text-sm text-gray-500">
Verwalten Sie die Mandanten des Portals.
</p>
</div>
<button onClick={openCreate} className="btn-primary gap-2">
<Plus size={18} />
Neuer Mandant
</button>
</div>
{/* Search */}
<div className="max-w-md">
<SearchBar
value={search}
onChange={(val) => {
setSearch(val);
setPage(1);
}}
placeholder="Mandanten suchen..."
/>
</div>
{/* Table */}
{loading ? (
<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>
) : tenants.length === 0 ? (
<EmptyState
icon={Building2}
title="Keine Mandanten gefunden"
description="Erstellen Sie den ersten Mandanten."
action={
<button onClick={openCreate} className="btn-primary gap-2">
<Plus size={18} />
Neuer Mandant
</button>
}
/>
) : (
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Slug
</th>
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500">
Aktionen
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{tenants.map((tenant) => (
<tr key={tenant.id} className="hover:bg-gray-50">
<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">
{tenant.name[0]}
</div>
<span className="font-medium text-gray-900">
{tenant.name}
</span>
</div>
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{tenant.slug}
</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">
<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">
<X size={12} />
Inaktiv
</span>
)}
</td>
<td className="whitespace-nowrap px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => openEdit(tenant)}
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
title="Bearbeiten"
>
<Edit size={16} />
</button>
<button
onClick={() => setDeactivateTarget(tenant)}
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
title={tenant.active ? 'Deaktivieren' : 'Aktivieren'}
>
<Power size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{totalPages > 1 && (
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
)}
{/* Create/Edit Modal */}
<Modal
open={modalOpen}
onClose={() => setModalOpen(false)}
title={editingTenant ? 'Mandant bearbeiten' : 'Neuer Mandant'}
>
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700">
Name *
</label>
<input
type="text"
value={formName}
onChange={(e) => {
setFormName(e.target.value);
if (!editingTenant) {
setFormSlug(
e.target.value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
);
}
}}
placeholder="Firmenname GmbH"
className="input-field"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700">
Slug *
</label>
<input
type="text"
value={formSlug}
onChange={(e) => setFormSlug(e.target.value)}
placeholder="firmenname-gmbh"
className="input-field"
/>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formActive}
onChange={(e) => setFormActive(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-gray-700">Aktiv</span>
</label>
<div className="flex justify-end gap-3 pt-2">
<button
onClick={() => setModalOpen(false)}
className="btn-secondary"
>
Abbrechen
</button>
<button
onClick={handleSave}
disabled={formSaving}
className="btn-primary"
>
{formSaving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</Modal>
{/* Deactivate Confirm */}
<ConfirmDialog
open={!!deactivateTarget}
onClose={() => setDeactivateTarget(null)}
onConfirm={handleDeactivate}
title={
deactivateTarget?.active
? 'Mandant deaktivieren?'
: 'Mandant aktivieren?'
}
message={
deactivateTarget?.active
? `Der Mandant "${deactivateTarget?.name}" wird deaktiviert. Benutzer des Mandanten können sich nicht mehr anmelden.`
: `Der Mandant "${deactivateTarget?.name}" wird wieder aktiviert.`
}
confirmLabel={deactivateTarget?.active ? 'Deaktivieren' : 'Aktivieren'}
variant={deactivateTarget?.active ? 'danger' : 'warning'}
loading={deactivating}
/>
</div>
);
}
+437
View File
@@ -0,0 +1,437 @@
import { useEffect, useState } from 'react';
import {
Check,
Edit,
Plus,
Power,
Users,
X,
} from 'lucide-react';
import toast from 'react-hot-toast';
import { usersApi, tenantsApi } from '../services/api';
import type { Tenant, User } from '../types';
import Modal from '../components/Modal';
import ConfirmDialog from '../components/ConfirmDialog';
import SearchBar from '../components/SearchBar';
import Pagination from '../components/Pagination';
import EmptyState from '../components/EmptyState';
const roleLabels: Record<User['role'], string> = {
SYSTEM_ADMIN: 'System-Admin',
TECHNICIAN: 'Techniker',
CUSTOMER_ADMIN: 'Kunden-Admin',
CUSTOMER_USER: 'Kunden-Benutzer',
};
const roleOptions: { value: User['role']; label: string }[] = [
{ value: 'SYSTEM_ADMIN', label: 'System-Admin' },
{ value: 'TECHNICIAN', label: 'Techniker' },
{ value: 'CUSTOMER_ADMIN', label: 'Kunden-Admin' },
{ value: 'CUSTOMER_USER', label: 'Kunden-Benutzer' },
];
export default function UserList() {
const [users, setUsers] = useState<User[]>([]);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState('');
const [tenantFilter, setTenantFilter] = useState('');
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(true);
// Modal state
const [modalOpen, setModalOpen] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [formFirstName, setFormFirstName] = useState('');
const [formLastName, setFormLastName] = useState('');
const [formEmail, setFormEmail] = useState('');
const [formPassword, setFormPassword] = useState('');
const [formRole, setFormRole] = useState<User['role']>('CUSTOMER_USER');
const [formTenantId, setFormTenantId] = useState('');
const [formSaving, setFormSaving] = useState(false);
// Deactivate dialog
const [deactivateTarget, setDeactivateTarget] = useState<User | null>(null);
const [deactivating, setDeactivating] = useState(false);
useEffect(() => {
tenantsApi.list({ pageSize: 100 }).then((r) => setTenants(r.data)).catch(() => {});
}, []);
useEffect(() => {
loadUsers();
}, [search, roleFilter, tenantFilter, page]);
const loadUsers = async () => {
setLoading(true);
try {
const res = await usersApi.list({
page,
pageSize: 20,
search: search || undefined,
role: roleFilter || undefined,
tenantId: tenantFilter || undefined,
});
setUsers(res.data);
setTotalPages(res.totalPages);
} catch {
toast.error('Benutzer konnten nicht geladen werden');
} finally {
setLoading(false);
}
};
const openCreate = () => {
setEditingUser(null);
setFormFirstName('');
setFormLastName('');
setFormEmail('');
setFormPassword('');
setFormRole('CUSTOMER_USER');
setFormTenantId('');
setModalOpen(true);
};
const openEdit = (user: User) => {
setEditingUser(user);
setFormFirstName(user.firstName);
setFormLastName(user.lastName);
setFormEmail(user.email);
setFormPassword('');
setFormRole(user.role);
setFormTenantId(user.tenantId || '');
setModalOpen(true);
};
const handleSave = async () => {
if (!formFirstName.trim() || !formLastName.trim() || !formEmail.trim()) {
toast.error('Vorname, Nachname und E-Mail sind erforderlich');
return;
}
if (!editingUser && !formPassword) {
toast.error('Passwort ist erforderlich');
return;
}
setFormSaving(true);
try {
const payload: Partial<User> & { password?: string } = {
firstName: formFirstName,
lastName: formLastName,
email: formEmail,
role: formRole,
tenantId: formTenantId || undefined,
};
if (formPassword) {
payload.password = formPassword;
}
if (editingUser) {
await usersApi.update(editingUser.id, payload);
toast.success('Benutzer aktualisiert');
} else {
await usersApi.create(payload);
toast.success('Benutzer erstellt');
}
setModalOpen(false);
loadUsers();
} catch {
toast.error('Speichern fehlgeschlagen');
} finally {
setFormSaving(false);
}
};
const handleDeactivate = async () => {
if (!deactivateTarget) return;
setDeactivating(true);
try {
await usersApi.delete(deactivateTarget.id);
toast.success('Benutzer deaktiviert');
setDeactivateTarget(null);
loadUsers();
} catch {
toast.error('Aktion fehlgeschlagen');
} finally {
setDeactivating(false);
}
};
return (
<div className="space-y-6">
{/* 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">Benutzer</h1>
<p className="mt-1 text-sm text-gray-500">
Verwalten Sie die Benutzer und deren Rollen.
</p>
</div>
<button onClick={openCreate} className="btn-primary gap-2">
<Plus size={18} />
Neuer Benutzer
</button>
</div>
{/* Filters */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="flex-1 sm:max-w-md">
<SearchBar
value={search}
onChange={(val) => {
setSearch(val);
setPage(1);
}}
placeholder="Benutzer suchen..."
/>
</div>
<select
value={roleFilter}
onChange={(e) => {
setRoleFilter(e.target.value);
setPage(1);
}}
className="input-field w-auto"
>
<option value="">Alle Rollen</option>
{roleOptions.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
<select
value={tenantFilter}
onChange={(e) => {
setTenantFilter(e.target.value);
setPage(1);
}}
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>
{/* Table */}
{loading ? (
<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>
) : users.length === 0 ? (
<EmptyState
icon={Users}
title="Keine Benutzer gefunden"
description="Erstellen Sie den ersten Benutzer."
action={
<button onClick={openCreate} className="btn-primary gap-2">
<Plus size={18} />
Neuer Benutzer
</button>
}
/>
) : (
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
E-Mail
</th>
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Rolle
</th>
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Mandant
</th>
<th className="px-6 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500">
Aktionen
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{users.map((u) => (
<tr key={u.id} className="hover:bg-gray-50">
<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-full bg-primary-100 text-sm font-semibold text-primary-700">
{u.firstName[0]}
{u.lastName[0]}
</div>
<span className="font-medium text-gray-900">
{u.firstName} {u.lastName}
</span>
</div>
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{u.email}
</td>
<td className="whitespace-nowrap px-6 py-4">
<span className="inline-flex rounded-full bg-primary-50 px-2.5 py-0.5 text-xs font-medium text-primary-700">
{roleLabels[u.role]}
</span>
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{u.tenant?.name || '-'}
</td>
<td className="whitespace-nowrap px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => openEdit(u)}
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
title="Bearbeiten"
>
<Edit size={16} />
</button>
<button
onClick={() => setDeactivateTarget(u)}
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-red-500"
title="Deaktivieren"
>
<Power size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{totalPages > 1 && (
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
)}
{/* Create/Edit Modal */}
<Modal
open={modalOpen}
onClose={() => setModalOpen(false)}
title={editingUser ? 'Benutzer bearbeiten' : 'Neuer Benutzer'}
size="lg"
>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700">
Vorname *
</label>
<input
type="text"
value={formFirstName}
onChange={(e) => setFormFirstName(e.target.value)}
placeholder="Max"
className="input-field"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700">
Nachname *
</label>
<input
type="text"
value={formLastName}
onChange={(e) => setFormLastName(e.target.value)}
placeholder="Mustermann"
className="input-field"
/>
</div>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700">
E-Mail *
</label>
<input
type="email"
value={formEmail}
onChange={(e) => setFormEmail(e.target.value)}
placeholder="max@firma.de"
className="input-field"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700">
Passwort {editingUser ? '(leer lassen um beizubehalten)' : '*'}
</label>
<input
type="password"
value={formPassword}
onChange={(e) => setFormPassword(e.target.value)}
placeholder={editingUser ? 'Neues Passwort' : 'Passwort'}
className="input-field"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700">
Rolle *
</label>
<select
value={formRole}
onChange={(e) => setFormRole(e.target.value as User['role'])}
className="input-field"
>
{roleOptions.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700">
Mandant
</label>
<select
value={formTenantId}
onChange={(e) => setFormTenantId(e.target.value)}
className="input-field"
>
<option value="">Kein Mandant</option>
{tenants.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
onClick={() => setModalOpen(false)}
className="btn-secondary"
>
Abbrechen
</button>
<button
onClick={handleSave}
disabled={formSaving}
className="btn-primary"
>
{formSaving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</Modal>
{/* Deactivate Confirm */}
<ConfirmDialog
open={!!deactivateTarget}
onClose={() => setDeactivateTarget(null)}
onConfirm={handleDeactivate}
title="Benutzer deaktivieren?"
message={`Der Benutzer "${deactivateTarget?.firstName} ${deactivateTarget?.lastName}" wird deaktiviert und kann sich nicht mehr anmelden.`}
confirmLabel="Deaktivieren"
loading={deactivating}
/>
</div>
);
}
+489
View File
@@ -0,0 +1,489 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
ArrowLeft,
GripVertical,
Plus,
Save,
Send,
Trash2,
X,
} from 'lucide-react';
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd';
import toast from 'react-hot-toast';
import { workflowsApi, categoriesApi } from '../services/api';
import type { Category, Workflow, WorkflowStep } from '../types';
import StepTypeIcon from '../components/StepTypeIcon';
interface StepDraft {
_key: string;
id?: string;
title: string;
content: string;
type: WorkflowStep['type'];
isRequired: boolean;
}
const stepTypes: { value: WorkflowStep['type']; label: string }[] = [
{ value: 'INSTRUCTION', label: 'Anleitung' },
{ value: 'CHECKLIST', label: 'Checkliste' },
{ value: 'WARNING', label: 'Warnung' },
{ value: 'INFO', label: 'Info' },
{ value: 'COMMAND', label: 'Befehl' },
];
let keyCounter = 0;
function nextKey() {
return `step_${++keyCounter}_${Date.now()}`;
}
export default function WorkflowEditor() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const isNew = !id;
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [categoryId, setCategoryId] = useState('');
const [tagsInput, setTagsInput] = useState('');
const [isTemplate, setIsTemplate] = useState(false);
const [steps, setSteps] = useState<StepDraft[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(!isNew);
const [saving, setSaving] = useState(false);
const [autoSaved, setAutoSaved] = useState(false);
const autoSaveTimer = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
categoriesApi.list().then(setCategories).catch(() => {});
}, []);
useEffect(() => {
if (!id) return;
setLoading(true);
workflowsApi
.get(id)
.then((wf) => {
setTitle(wf.title);
setDescription(wf.description || '');
setCategoryId(wf.categoryId || '');
setTagsInput(wf.tags.join(', '));
setIsTemplate(wf.isTemplate);
const sorted = [...(wf.steps || [])].sort((a, b) => a.order - b.order);
setSteps(
sorted.map((s) => ({
_key: nextKey(),
id: s.id,
title: s.title,
content: s.content,
type: s.type,
isRequired: s.isRequired,
}))
);
})
.catch(() => {
toast.error('Workflow konnte nicht geladen werden');
navigate('/workflows');
})
.finally(() => setLoading(false));
}, [id, navigate]);
const parseTags = (): string[] =>
tagsInput
.split(',')
.map((t) => t.trim())
.filter(Boolean);
const buildPayload = (status?: Workflow['status']) => ({
title,
description: description || undefined,
categoryId: categoryId || undefined,
tags: parseTags(),
isTemplate,
status,
steps: steps.map((s, idx) => ({
id: s.id,
title: s.title,
content: s.content,
type: s.type,
isRequired: s.isRequired,
order: idx + 1,
})),
});
const handleSave = useCallback(
async (status?: Workflow['status']) => {
if (!title.trim()) {
toast.error('Bitte geben Sie einen Titel ein');
return;
}
setSaving(true);
try {
const payload = buildPayload(status);
if (isNew) {
const created = await workflowsApi.create({
...payload,
status: status || 'DRAFT',
} as Partial<Workflow>);
toast.success('Workflow erstellt');
navigate(`/workflows/${created.id}/edit`, { replace: true });
} else {
await workflowsApi.update(id!, payload as Partial<Workflow>);
toast.success('Workflow gespeichert');
}
} catch {
toast.error('Speichern fehlgeschlagen');
} finally {
setSaving(false);
}
},
[title, description, categoryId, tagsInput, isTemplate, steps, isNew, id, navigate]
);
// Auto-save (only for existing workflows)
useEffect(() => {
if (isNew || loading) return;
clearTimeout(autoSaveTimer.current);
autoSaveTimer.current = setTimeout(async () => {
if (!title.trim()) return;
try {
await workflowsApi.update(id!, buildPayload() as Partial<Workflow>);
setAutoSaved(true);
setTimeout(() => setAutoSaved(false), 2000);
} catch {
// silent
}
}, 5000);
return () => clearTimeout(autoSaveTimer.current);
}, [title, description, categoryId, tagsInput, isTemplate, steps, isNew, loading, id]);
const addStep = () => {
setSteps((prev) => [
...prev,
{
_key: nextKey(),
title: '',
content: '',
type: 'INSTRUCTION',
isRequired: false,
},
]);
};
const removeStep = (index: number) => {
setSteps((prev) => prev.filter((_, i) => i !== index));
};
const updateStep = (index: number, field: keyof StepDraft, value: unknown) => {
setSteps((prev) =>
prev.map((s, i) => (i === index ? { ...s, [field]: value } : s))
);
};
const handleDragEnd = (result: DropResult) => {
if (!result.destination) return;
const from = result.source.index;
const to = result.destination.index;
if (from === to) return;
setSteps((prev) => {
const next = [...prev];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
});
};
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>
);
}
return (
<div className="mx-auto max-w-4xl space-y-6">
{/* Back */}
<div className="flex items-center justify-between">
<button
onClick={() => navigate(-1)}
className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700"
>
<ArrowLeft size={16} />
Zurück
</button>
{autoSaved && (
<span className="text-xs text-green-600">Automatisch gespeichert</span>
)}
</div>
<h1 className="text-2xl font-bold text-gray-900">
{isNew ? 'Neuer Workflow' : 'Workflow bearbeiten'}
</h1>
{/* Metadata */}
<div className="card space-y-5 p-6">
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700">
Titel *
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="z.B. Server-Neustart Anleitung"
className="input-field"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700">
Beschreibung
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Kurze Beschreibung des Workflows..."
rows={3}
className="input-field resize-none"
/>
</div>
<div className="grid gap-5 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700">
Kategorie
</label>
<select
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
className="input-field"
>
<option value="">Keine Kategorie</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700">
Tags (kommagetrennt)
</label>
<input
type="text"
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder="server, linux, wartung"
className="input-field"
/>
</div>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={isTemplate}
onChange={(e) => setIsTemplate(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-gray-700">Als Vorlage speichern</span>
</label>
</div>
{/* Steps */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Schritte</h2>
<button onClick={addStep} className="btn-secondary gap-1.5 text-sm">
<Plus size={16} />
Schritt hinzufügen
</button>
</div>
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="steps">
{(provided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className="space-y-3"
>
{steps.map((step, index) => (
<Draggable
key={step._key}
draggableId={step._key}
index={index}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
className={`card overflow-hidden ${
snapshot.isDragging ? 'shadow-lg ring-2 ring-primary-300' : ''
}`}
>
<div className="flex items-center gap-2 border-b border-gray-100 bg-gray-50 px-4 py-2">
<div
{...provided.dragHandleProps}
className="cursor-grab text-gray-400 hover:text-gray-600"
>
<GripVertical size={18} />
</div>
<span className="text-sm font-medium text-gray-500">
Schritt {index + 1}
</span>
<StepTypeIcon type={step.type} />
<div className="flex-1" />
<button
onClick={() => removeStep(index)}
className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-500"
>
<Trash2 size={16} />
</button>
</div>
<div className="space-y-4 p-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">
Titel
</label>
<input
type="text"
value={step.title}
onChange={(e) =>
updateStep(index, 'title', e.target.value)
}
placeholder="Schritt-Titel"
className="input-field"
/>
</div>
<div className="flex gap-4">
<div className="flex-1">
<label className="mb-1 block text-xs font-medium text-gray-500">
Typ
</label>
<select
value={step.type}
onChange={(e) =>
updateStep(
index,
'type',
e.target.value as WorkflowStep['type']
)
}
className="input-field"
>
{stepTypes.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={step.isRequired}
onChange={(e) =>
updateStep(
index,
'isRequired',
e.target.checked
)
}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-xs text-gray-600">
Pflicht
</span>
</label>
</div>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">
Inhalt
</label>
<textarea
value={step.content}
onChange={(e) =>
updateStep(index, 'content', e.target.value)
}
placeholder={
step.type === 'COMMAND'
? 'Befehl eingeben...'
: 'Schritt-Inhalt beschreiben...'
}
rows={step.type === 'COMMAND' ? 3 : 4}
className={`input-field resize-none ${
step.type === 'COMMAND'
? 'font-mono text-sm'
: ''
}`}
/>
</div>
</div>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
{steps.length === 0 && (
<div className="card py-12 text-center">
<p className="text-sm text-gray-500">
Noch keine Schritte vorhanden.
</p>
<button onClick={addStep} className="btn-primary mt-4 gap-1.5">
<Plus size={16} />
Ersten Schritt hinzufügen
</button>
</div>
)}
</div>
{/* Actions */}
<div className="card flex flex-wrap items-center justify-end gap-3 p-4">
<button
onClick={() => navigate(-1)}
className="btn-secondary"
>
Abbrechen
</button>
<button
onClick={() => handleSave('DRAFT')}
disabled={saving}
className="btn-secondary gap-1.5"
>
<Save size={16} />
Als Entwurf speichern
</button>
<button
onClick={() => handleSave('REVIEW')}
disabled={saving}
className="btn-secondary gap-1.5"
>
<Send size={16} />
Zur Prüfung einreichen
</button>
<button
onClick={() => handleSave('PUBLISHED')}
disabled={saving}
className="btn-primary gap-1.5"
>
<Send size={16} />
Veröffentlichen
</button>
</div>
</div>
);
}
+274
View File
@@ -0,0 +1,274 @@
import { useEffect, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import {
FileText,
Grid3X3,
List,
Plus,
Tag,
} from 'lucide-react';
import toast from 'react-hot-toast';
import { workflowsApi, categoriesApi } from '../services/api';
import type { Category, Workflow } from '../types';
import SearchBar from '../components/SearchBar';
import StatusBadge from '../components/StatusBadge';
import Pagination from '../components/Pagination';
import EmptyState from '../components/EmptyState';
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 WorkflowList() {
const [searchParams, setSearchParams] = useSearchParams();
const isTemplate = searchParams.get('template') === 'true';
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(true);
const [viewMode, setViewMode] = useState<ViewMode>('grid');
useEffect(() => {
categoriesApi.list().then(setCategories).catch(() => {});
}, []);
useEffect(() => {
loadWorkflows();
}, [search, statusFilter, categoryFilter, page, isTemplate]);
const loadWorkflows = async () => {
setLoading(true);
try {
const params: Parameters<typeof workflowsApi.list>[0] = {
page,
pageSize: 12,
search: search || undefined,
status: statusFilter || undefined,
categoryId: categoryFilter || undefined,
isTemplate: isTemplate || undefined,
};
const res = await workflowsApi.list(params);
setWorkflows(res.data);
setTotalPages(res.totalPages);
} catch {
toast.error('Workflows konnten nicht geladen werden');
} finally {
setLoading(false);
}
};
return (
<div className="space-y-6">
{/* 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">
{isTemplate ? 'Vorlagen' : 'Workflows'}
</h1>
<p className="mt-1 text-sm text-gray-500">
{isTemplate
? 'Wiederverwendbare Workflow-Vorlagen'
: 'Alle Workflows Ihres Mandanten'}
</p>
</div>
<Link to="/workflows/new" className="btn-primary gap-2">
<Plus size={18} />
Neuer Workflow
</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>
<select
value={categoryFilter}
onChange={(e) => {
setCategoryFilter(e.target.value);
setPage(1);
}}
className="input-field w-auto"
>
<option value="">Alle Kategorien</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<div className="flex rounded-lg border border-gray-300 bg-white">
<button
onClick={() => setViewMode('grid')}
className={`rounded-l-lg p-2 ${
viewMode === 'grid'
? 'bg-primary-50 text-primary-600'
: 'text-gray-400 hover:text-gray-600'
}`}
>
<Grid3X3 size={18} />
</button>
<button
onClick={() => setViewMode('list')}
className={`rounded-r-lg p-2 ${
viewMode === 'list'
? 'bg-primary-50 text-primary-600'
: 'text-gray-400 hover:text-gray-600'
}`}
>
<List size={18} />
</button>
</div>
</div>
</div>
{/* Results */}
{loading ? (
<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 || categoryFilter
? 'Versuchen Sie andere Suchkriterien.'
: 'Erstellen Sie Ihren ersten Workflow.'
}
action={
<Link to="/workflows/new" 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">
{wf.title}
</h3>
<StatusBadge status={wf.status} />
</div>
{wf.description && (
<p className="mt-2 line-clamp-2 text-sm text-gray-500">
{wf.description}
</p>
)}
<div className="mt-4 flex items-center justify-between text-xs text-gray-400">
<span>
{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">
<Tag size={12} />
{wf.category.name}
</div>
)}
{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"
>
{tag}
</span>
))}
{wf.tags.length > 3 && (
<span className="text-xs text-gray-400">
+{wf.tags.length - 3}
</span>
)}
</div>
)}
</Link>
))}
</div>
) : (
<div className="card divide-y divide-gray-100">
{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"
>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
<FileText className="text-gray-500" size={20} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-gray-900">{wf.title}</p>
{wf.description && (
<p className="truncate text-sm text-gray-500">
{wf.description}
</p>
)}
</div>
{wf.category && (
<span className="hidden rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 md:inline">
{wf.category.name}
</span>
)}
<span className="hidden text-sm text-gray-400 sm:inline">
{wf.steps?.length || 0} Schritte
</span>
<StatusBadge status={wf.status} />
<span className="hidden text-xs text-gray-400 lg:inline">
{new Date(wf.updatedAt).toLocaleDateString('de-DE')}
</span>
</Link>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
)}
</div>
);
}
+265
View File
@@ -0,0 +1,265 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import {
AlertTriangle,
Archive,
ArrowLeft,
CheckCircle2,
CheckSquare,
Copy,
Edit,
FileText,
Info,
Send,
Terminal,
} from 'lucide-react';
import toast from 'react-hot-toast';
import { workflowsApi } from '../services/api';
import type { Workflow, WorkflowStep } from '../types';
import StatusBadge from '../components/StatusBadge';
import StepTypeIcon from '../components/StepTypeIcon';
import ConfirmDialog from '../components/ConfirmDialog';
function getStepBgClass(type: WorkflowStep['type']): string {
switch (type) {
case 'WARNING':
return 'border-l-4 border-l-amber-400 bg-amber-50';
case 'COMMAND':
return 'border-l-4 border-l-purple-400 bg-gray-900';
case 'INFO':
return 'border-l-4 border-l-cyan-400 bg-cyan-50';
case 'CHECKLIST':
return 'border-l-4 border-l-green-400 bg-green-50';
default:
return 'border-l-4 border-l-primary-400 bg-white';
}
}
export default function WorkflowView() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [workflow, setWorkflow] = useState<Workflow | null>(null);
const [loading, setLoading] = useState(true);
const [archiveOpen, setArchiveOpen] = useState(false);
const [archiving, setArchiving] = useState(false);
useEffect(() => {
if (!id) return;
setLoading(true);
workflowsApi
.get(id)
.then(setWorkflow)
.catch(() => {
toast.error('Workflow konnte nicht geladen werden');
navigate('/workflows');
})
.finally(() => setLoading(false));
}, [id, navigate]);
const handleDuplicate = async () => {
if (!id) return;
try {
const dup = await workflowsApi.duplicate(id);
toast.success('Workflow dupliziert');
navigate(`/workflows/${dup.id}/edit`);
} catch {
toast.error('Duplizieren fehlgeschlagen');
}
};
const handleArchive = async () => {
if (!id) return;
setArchiving(true);
try {
await workflowsApi.archive(id);
toast.success('Workflow archiviert');
setArchiveOpen(false);
navigate('/workflows');
} catch {
toast.error('Archivieren fehlgeschlagen');
} finally {
setArchiving(false);
}
};
const handlePublish = async () => {
if (!id) return;
try {
const updated = await workflowsApi.publish(id);
setWorkflow(updated);
toast.success('Workflow veröffentlicht');
} catch {
toast.error('Veröffentlichen fehlgeschlagen');
}
};
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 (!workflow) return null;
const sortedSteps = [...(workflow.steps || [])].sort(
(a, b) => a.order - b.order
);
return (
<div className="mx-auto max-w-4xl space-y-6">
{/* Back */}
<Link
to="/workflows"
className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700"
>
<ArrowLeft size={16} />
Zurück zu Workflows
</Link>
{/* Header */}
<div className="card p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900">
{workflow.title}
</h1>
<StatusBadge status={workflow.status} />
</div>
{workflow.description && (
<p className="mt-2 text-gray-600">{workflow.description}</p>
)}
<div className="mt-4 flex flex-wrap items-center gap-4 text-sm text-gray-500">
<span>Version {workflow.version}</span>
<span>{sortedSteps.length} Schritte</span>
{workflow.category && <span>{workflow.category.name}</span>}
<span>
Erstellt am{' '}
{new Date(workflow.createdAt).toLocaleDateString('de-DE')}
</span>
<span>
Aktualisiert am{' '}
{new Date(workflow.updatedAt).toLocaleDateString('de-DE')}
</span>
</div>
{workflow.tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{workflow.tags.map((tag) => (
<span
key={tag}
className="rounded-full bg-primary-50 px-3 py-0.5 text-xs font-medium text-primary-700"
>
{tag}
</span>
))}
</div>
)}
</div>
<div className="flex flex-wrap gap-2">
<Link
to={`/workflows/${workflow.id}/edit`}
className="btn-secondary gap-1.5"
>
<Edit size={16} />
Bearbeiten
</Link>
<button onClick={handleDuplicate} className="btn-secondary gap-1.5">
<Copy size={16} />
Duplizieren
</button>
{(workflow.status === 'DRAFT' || workflow.status === 'REVIEW') && (
<button onClick={handlePublish} className="btn-primary gap-1.5">
<Send size={16} />
Veröffentlichen
</button>
)}
{workflow.status !== 'ARCHIVED' && (
<button
onClick={() => setArchiveOpen(true)}
className="btn-secondary gap-1.5 text-red-600 hover:bg-red-50"
>
<Archive size={16} />
Archivieren
</button>
)}
</div>
</div>
</div>
{/* Steps */}
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Schritte</h2>
{sortedSteps.length === 0 ? (
<div className="card py-12 text-center text-sm text-gray-500">
Keine Schritte vorhanden.
</div>
) : (
<div className="space-y-3">
{sortedSteps.map((step, idx) => (
<div
key={step.id}
className={`card overflow-hidden ${getStepBgClass(step.type)}`}
>
<div className="p-5">
<div className="flex items-start gap-4">
{/* Step number */}
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-white text-sm font-bold text-gray-700 shadow-sm ring-1 ring-gray-200">
{idx + 1}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<StepTypeIcon type={step.type} showLabel />
{step.isRequired && (
<span className="rounded bg-red-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-red-600">
Pflicht
</span>
)}
</div>
<h3
className={`mt-1 font-semibold ${
step.type === 'COMMAND' ? 'text-gray-100' : 'text-gray-900'
}`}
>
{step.title}
</h3>
{step.type === 'COMMAND' ? (
<pre className="mt-3 overflow-x-auto rounded-lg bg-gray-800 p-4 font-mono text-sm text-green-400">
<code>{step.content}</code>
</pre>
) : (
<div
className={`mt-2 whitespace-pre-wrap text-sm ${
step.type === 'WARNING'
? 'text-amber-800'
: step.type === 'INFO'
? 'text-cyan-800'
: 'text-gray-600'
}`}
>
{step.content}
</div>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
<ConfirmDialog
open={archiveOpen}
onClose={() => setArchiveOpen(false)}
onConfirm={handleArchive}
title="Workflow archivieren?"
message="Der Workflow wird archiviert und ist nicht mehr aktiv verfügbar. Diese Aktion kann rückgängig gemacht werden."
confirmLabel="Archivieren"
loading={archiving}
/>
</div>
);
}
+221
View File
@@ -0,0 +1,221 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import type {
AuthResponse,
Category,
PaginatedResponse,
Tenant,
User,
Workflow,
WorkflowStep,
} from '../types';
const api = axios.create({
baseURL: '/api',
headers: { 'Content-Type': 'application/json' },
});
let isRefreshing = false;
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (err: unknown) => void;
}> = [];
function processQueue(error: unknown, token: string | null = null) {
failedQueue.forEach((p) => {
if (error) {
p.reject(error);
} else {
p.resolve(token!);
}
});
failedQueue = [];
}
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('accessToken');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: (token: string) => {
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${token}`;
}
resolve(api(originalRequest));
},
reject,
});
});
}
originalRequest._retry = true;
isRefreshing = true;
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject(error);
}
try {
const { data } = await axios.post<AuthResponse>('/api/auth/refresh', {
refreshToken,
});
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
processQueue(null, data.accessToken);
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
}
return api(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
// --- Auth ---
export const authApi = {
login: (email: string, password: string) =>
api.post<AuthResponse>('/auth/login', { email, password }).then((r) => r.data),
refresh: (refreshToken: string) =>
api.post<AuthResponse>('/auth/refresh', { refreshToken }).then((r) => r.data),
me: () => api.get<User>('/auth/me').then((r) => r.data),
logout: () => api.post('/auth/logout').then((r) => r.data),
};
// --- Tenants ---
export const tenantsApi = {
list: (params?: { page?: number; pageSize?: number; search?: string }) =>
api.get<PaginatedResponse<Tenant>>('/tenants', { params }).then((r) => r.data),
get: (id: string) => api.get<Tenant>(`/tenants/${id}`).then((r) => r.data),
create: (data: Partial<Tenant>) =>
api.post<Tenant>('/tenants', data).then((r) => r.data),
update: (id: string, data: Partial<Tenant>) =>
api.put<Tenant>(`/tenants/${id}`, data).then((r) => r.data),
delete: (id: string) => api.delete(`/tenants/${id}`).then((r) => r.data),
};
// --- Users ---
export const usersApi = {
list: (params?: {
page?: number;
pageSize?: number;
search?: string;
tenantId?: string;
role?: string;
}) => api.get<PaginatedResponse<User>>('/users', { params }).then((r) => r.data),
get: (id: string) => api.get<User>(`/users/${id}`).then((r) => r.data),
create: (data: Partial<User> & { password?: string }) =>
api.post<User>('/users', data).then((r) => r.data),
update: (id: string, data: Partial<User>) =>
api.put<User>(`/users/${id}`, data).then((r) => r.data),
delete: (id: string) => api.delete(`/users/${id}`).then((r) => r.data),
};
// --- Workflows ---
export const workflowsApi = {
list: (params?: {
page?: number;
pageSize?: number;
search?: string;
status?: string;
categoryId?: string;
tenantId?: string;
isTemplate?: boolean;
tags?: string;
}) =>
api
.get<PaginatedResponse<Workflow>>('/workflows', { params })
.then((r) => r.data),
get: (id: string) => api.get<Workflow>(`/workflows/${id}`).then((r) => r.data),
create: (data: Partial<Workflow>) =>
api.post<Workflow>('/workflows', data).then((r) => r.data),
update: (id: string, data: Partial<Workflow>) =>
api.put<Workflow>(`/workflows/${id}`, data).then((r) => r.data),
delete: (id: string) => api.delete(`/workflows/${id}`).then((r) => r.data),
duplicate: (id: string) =>
api.post<Workflow>(`/workflows/${id}/duplicate`).then((r) => r.data),
publish: (id: string) =>
api.post<Workflow>(`/workflows/${id}/publish`).then((r) => r.data),
archive: (id: string) =>
api.post<Workflow>(`/workflows/${id}/archive`).then((r) => r.data),
};
// --- Workflow Steps ---
export const stepsApi = {
create: (workflowId: string, data: Partial<WorkflowStep>) =>
api
.post<WorkflowStep>(`/workflows/${workflowId}/steps`, data)
.then((r) => r.data),
update: (workflowId: string, stepId: string, data: Partial<WorkflowStep>) =>
api
.put<WorkflowStep>(`/workflows/${workflowId}/steps/${stepId}`, data)
.then((r) => r.data),
delete: (workflowId: string, stepId: string) =>
api.delete(`/workflows/${workflowId}/steps/${stepId}`).then((r) => r.data),
reorder: (workflowId: string, stepIds: string[]) =>
api
.put(`/workflows/${workflowId}/steps/reorder`, { stepIds })
.then((r) => r.data),
};
// --- Categories ---
export const categoriesApi = {
list: () => api.get<Category[]>('/categories').then((r) => r.data),
create: (data: Partial<Category>) =>
api.post<Category>('/categories', data).then((r) => r.data),
update: (id: string, data: Partial<Category>) =>
api.put<Category>(`/categories/${id}`, data).then((r) => r.data),
delete: (id: string) => api.delete(`/categories/${id}`).then((r) => r.data),
};
export default api;
+64
View File
@@ -0,0 +1,64 @@
export interface Tenant {
id: string;
name: string;
slug: string;
logo?: string;
active: boolean;
}
export interface User {
id: string;
email: string;
firstName: string;
lastName: string;
role: 'SYSTEM_ADMIN' | 'TECHNICIAN' | 'CUSTOMER_ADMIN' | 'CUSTOMER_USER';
tenantId?: string;
tenant?: Tenant;
}
export interface Workflow {
id: string;
title: string;
description?: string;
tenantId: string;
categoryId?: string;
category?: Category;
status: 'DRAFT' | 'REVIEW' | 'PUBLISHED' | 'ARCHIVED';
version: number;
isTemplate: boolean;
tags: string[];
steps: WorkflowStep[];
createdBy: User;
createdAt: string;
updatedAt: string;
}
export interface WorkflowStep {
id: string;
workflowId: string;
order: number;
title: string;
content: string;
type: 'INSTRUCTION' | 'CHECKLIST' | 'WARNING' | 'INFO' | 'COMMAND';
isRequired: boolean;
}
export interface Category {
id: string;
name: string;
icon?: string;
}
export interface AuthResponse {
accessToken: string;
refreshToken: string;
user: User;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
+29
View File
@@ -0,0 +1,29 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554',
},
brand: {
DEFAULT: '#2563eb',
dark: '#1d4ed8',
light: '#3b82f6',
},
},
},
},
plugins: [],
};
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:4000',
changeOrigin: true,
},
},
},
});