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
+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>
);
}