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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user