first commit

This commit is contained in:
Stefan Hacker
2026-01-29 01:16:54 +01:00
commit 31f807fbd0
12106 changed files with 2480685 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
export default function ScrollToTop() {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
return null;
}
@@ -0,0 +1,42 @@
import { useState, useEffect } from 'react';
import { ChevronUp } from 'lucide-react';
import { useAppSettings } from '../context/AppSettingsContext';
export default function ScrollToTopButton() {
const { settings } = useAppSettings();
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const toggleVisibility = () => {
// Show button when scrolled past configured threshold
if (window.scrollY > window.innerHeight * settings.scrollToTopThreshold) {
setIsVisible(true);
} else {
setIsVisible(false);
}
};
window.addEventListener('scroll', toggleVisibility);
return () => window.removeEventListener('scroll', toggleVisibility);
}, [settings.scrollToTopThreshold]);
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
};
if (!isVisible) return null;
return (
<button
onClick={scrollToTop}
className="fixed bottom-6 right-6 p-3 bg-gray-200 hover:bg-gray-300 text-gray-600 rounded-full shadow-md transition-all duration-300 opacity-70 hover:opacity-100 z-50"
aria-label="Nach oben scrollen"
title="Nach oben"
>
<ChevronUp className="w-5 h-5" />
</button>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { Outlet } from 'react-router-dom';
import Sidebar from './Sidebar';
import ScrollToTopButton from '../ScrollToTopButton';
export default function Layout() {
return (
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1 p-8 overflow-auto">
<Outlet />
</main>
<ScrollToTopButton />
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { NavLink } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import {
LayoutDashboard,
Users,
FileText,
LogOut,
Settings,
Code,
Database,
ClipboardList,
MessageSquare,
AlertCircle,
} from 'lucide-react';
export default function Sidebar() {
const { user, logout, hasPermission, isCustomer, developerMode } = useAuth();
const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', show: true, end: true },
{ to: '/customers', icon: Users, label: 'Kunden', show: hasPermission('customers:read') && !isCustomer },
{ to: '/contracts', icon: FileText, label: 'Verträge', show: hasPermission('contracts:read'), end: true },
{ to: '/contracts/cockpit', icon: AlertCircle, label: 'Vertrags-Cockpit', show: hasPermission('contracts:read') && !isCustomer },
{ to: '/tasks', icon: isCustomer ? MessageSquare : ClipboardList, label: isCustomer ? 'Support-Anfragen' : 'Aufgaben', show: hasPermission('contracts:read') },
];
const developerItems = [
{ to: '/developer/database', icon: Database, label: 'Datenbankstruktur' },
];
return (
<aside className="w-64 bg-gray-900 text-white min-h-screen flex flex-col">
<div className="p-4 border-b border-gray-800">
<h1 className="text-xl font-bold">OpenCRM</h1>
</div>
<nav className="flex-1 p-4 overflow-y-auto">
<ul className="space-y-2">
{navItems
.filter((item) => item.show)
.map((item) => (
<li key={item.to}>
<NavLink
to={item.to}
end={item.end}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-2 rounded-lg transition-colors ${
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-800'
}`
}
>
<item.icon className="w-5 h-5" />
{item.label}
</NavLink>
</li>
))}
</ul>
{/* Entwicklermodus-Menü */}
{developerMode && hasPermission('developer:access') && (
<>
<div className="mt-6 mb-2 px-4">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wider flex items-center gap-2">
<Code className="w-3 h-3" />
Entwickler
</p>
</div>
<ul className="space-y-2">
{developerItems.map((item) => (
<li key={item.to}>
<NavLink
to={item.to}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-2 rounded-lg transition-colors ${
isActive
? 'bg-purple-600 text-white'
: 'text-purple-300 hover:bg-gray-800'
}`
}
>
<item.icon className="w-5 h-5" />
{item.label}
</NavLink>
</li>
))}
</ul>
</>
)}
{/* Einstellungen */}
<div className="mt-6 pt-6 border-t border-gray-800">
<NavLink
to="/settings"
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-2 rounded-lg transition-colors ${
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-800'
}`
}
>
<Settings className="w-5 h-5" />
Einstellungen
</NavLink>
</div>
</nav>
<div className="p-4 border-t border-gray-800">
<div className="mb-4 text-sm">
<p className="text-gray-400">Angemeldet als</p>
<p className="font-medium">{user?.firstName} {user?.lastName}</p>
</div>
<button
onClick={logout}
className="flex items-center gap-3 w-full px-4 py-2 text-gray-300 hover:bg-gray-800 rounded-lg transition-colors"
>
<LogOut className="w-5 h-5" />
Abmelden
</button>
</div>
</aside>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { ReactNode } from 'react';
interface BadgeProps {
children: ReactNode;
variant?: 'default' | 'success' | 'warning' | 'danger' | 'info';
className?: string;
onClick?: () => void;
}
export default function Badge({ children, variant = 'default', className = '', onClick }: BadgeProps) {
const variantClasses = {
default: 'bg-gray-100 text-gray-800',
success: 'bg-green-100 text-green-800',
warning: 'bg-yellow-100 text-yellow-800',
danger: 'bg-red-100 text-red-800',
info: 'bg-blue-100 text-blue-800',
};
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${variantClasses[variant]} ${className}`}
onClick={onClick}
>
{children}
</span>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { ButtonHTMLAttributes, forwardRef } from 'react';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className = '', variant = 'primary', size = 'md', children, disabled, ...props }, ref) => {
const baseClasses = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
const variantClasses = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
ghost: 'bg-transparent text-gray-700 hover:bg-gray-100 focus:ring-gray-500',
};
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
};
return (
<button
ref={ref}
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
disabled={disabled}
{...props}
>
{children}
</button>
);
}
);
Button.displayName = 'Button';
export default Button;
+22
View File
@@ -0,0 +1,22 @@
import { ReactNode } from 'react';
interface CardProps {
children: ReactNode;
className?: string;
title?: string;
actions?: ReactNode;
}
export default function Card({ children, className = '', title, actions }: CardProps) {
return (
<div className={`bg-white rounded-lg shadow ${className}`}>
{(title || actions) && (
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
{title && <h3 className="text-lg font-medium text-gray-900">{title}</h3>}
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
)}
<div className="p-6">{children}</div>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { useState } from 'react';
import { Copy, Check } from 'lucide-react';
interface CopyButtonProps {
value: string;
className?: string;
size?: 'sm' | 'md';
title?: string;
}
export default function CopyButton({ value, className = '', size = 'sm', title = 'In Zwischenablage kopieren' }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch (err) {
console.error('Failed to copy:', err);
}
};
const iconSize = size === 'sm' ? 'w-3.5 h-3.5' : 'w-4 h-4';
return (
<button
type="button"
onClick={handleCopy}
className={`inline-flex items-center justify-center p-1 rounded transition-colors ${
copied
? 'text-green-600 bg-green-50'
: 'text-gray-400 hover:text-blue-600 hover:bg-blue-50'
} ${className}`}
title={copied ? 'Kopiert!' : title}
>
{copied ? (
<Check className={iconSize} />
) : (
<Copy className={iconSize} />
)}
</button>
);
}
// Wrapper component for displaying a value with copy button
interface CopyableValueProps {
value: string | number | null | undefined;
label?: string;
className?: string;
multiline?: boolean;
}
export function CopyableValue({ value, label, className = '', multiline = false }: CopyableValueProps) {
if (value === null || value === undefined || value === '') return null;
const stringValue = String(value);
return (
<div className={className}>
{label && <dt className="text-sm text-gray-500">{label}</dt>}
<dd className={`flex items-start gap-1 ${multiline ? '' : 'items-center'}`}>
<span className={multiline ? 'whitespace-pre-line' : ''}>{stringValue}</span>
<CopyButton value={stringValue} className="flex-shrink-0 ml-1" />
</dd>
</div>
);
}
// For copying multiple values at once (e.g., full address)
interface CopyableBlockProps {
values: (string | number | null | undefined)[];
separator?: string;
children: React.ReactNode;
className?: string;
}
export function CopyableBlock({ values, separator = '\n', children, className = '' }: CopyableBlockProps) {
const combinedValue = values
.filter(v => v !== null && v !== undefined && v !== '')
.map(String)
.join(separator);
if (!combinedValue) return <>{children}</>;
return (
<div className={`relative group ${className}`}>
{children}
<CopyButton
value={combinedValue}
className="absolute top-0 right-0 opacity-60 group-hover:opacity-100"
title="Alles kopieren"
/>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { useRef, useState } from 'react';
import { Upload } from 'lucide-react';
import Button from './Button';
interface FileUploadProps {
onUpload: (file: File) => Promise<void>;
existingFile?: string;
accept?: string;
label?: string;
disabled?: boolean;
}
export default function FileUpload({
onUpload,
existingFile,
accept = '.pdf,.jpg,.jpeg,.png',
label = 'Dokument hochladen',
disabled = false,
}: FileUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const handleFileSelect = async (file: File) => {
if (!file) return;
setIsUploading(true);
try {
await onUpload(file);
} catch (error) {
console.error('Upload failed:', error);
alert('Upload fehlgeschlagen');
} finally {
setIsUploading(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleFileSelect(file);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files?.[0];
if (file) {
handleFileSelect(file);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(true);
};
const handleDragLeave = () => {
setDragOver(false);
};
return (
<div className="space-y-2">
{existingFile ? (
!disabled && (
<Button
variant="secondary"
size="sm"
onClick={() => inputRef.current?.click()}
disabled={isUploading}
>
{isUploading ? 'Wird hochgeladen...' : 'Ersetzen'}
</Button>
)
) : (
<div
className={`border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors ${
dragOver
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 hover:border-gray-400'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={() => !disabled && inputRef.current?.click()}
onDrop={!disabled ? handleDrop : undefined}
onDragOver={!disabled ? handleDragOver : undefined}
onDragLeave={!disabled ? handleDragLeave : undefined}
>
{isUploading ? (
<div className="text-gray-500">
<div className="animate-spin w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-2" />
Wird hochgeladen...
</div>
) : (
<>
<Upload className="w-6 h-6 text-gray-400 mx-auto mb-2" />
<p className="text-sm text-gray-600">{label}</p>
<p className="text-xs text-gray-400 mt-1">PDF, JPG oder PNG (max. 10MB)</p>
</>
)}
</div>
)}
<input
ref={inputRef}
type="file"
accept={accept}
onChange={handleChange}
className="hidden"
disabled={disabled || isUploading}
/>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { InputHTMLAttributes, forwardRef } from 'react';
import { Trash2 } from 'lucide-react';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
onClear?: () => void;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ className = '', label, error, id, onClear, ...props }, ref) => {
const inputId = id || props.name;
const isDateType = props.type === 'date';
const hasValue = props.value !== undefined && props.value !== null && props.value !== '';
const showClearButton = isDateType && onClear && hasValue;
return (
<div className="w-full">
{label && (
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
)}
<div className={showClearButton ? 'flex gap-2' : ''}>
<input
ref={ref}
id={inputId}
className={`block w-full px-3 py-2 border rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
error ? 'border-red-500' : 'border-gray-300'
} ${className}`}
{...props}
/>
{showClearButton && (
<button
type="button"
onClick={onClear}
className="px-3 py-2 text-gray-400 hover:text-red-500 hover:bg-red-50 border border-gray-300 rounded-lg transition-colors"
title="Datum löschen"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
</div>
);
}
);
Input.displayName = 'Input';
export default Input;
+50
View File
@@ -0,0 +1,50 @@
import { ReactNode, useEffect } from 'react';
import { X } from 'lucide-react';
import Button from './Button';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl';
}
export default function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) {
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
if (!isOpen) return null;
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
};
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className={`relative bg-white rounded-lg shadow-xl w-full ${sizeClasses[size]}`}>
<div className="flex items-center justify-between px-6 py-4 border-b">
<h2 className="text-lg font-semibold">{title}</h2>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="w-5 h-5" />
</Button>
</div>
<div className="p-6">{children}</div>
</div>
</div>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { SelectHTMLAttributes, forwardRef } from 'react';
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: string;
options: { value: string | number; label: string }[];
placeholder?: string;
}
const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ className = '', label, error, options, id, placeholder = 'Bitte wählen...', ...props }, ref) => {
const selectId = id || props.name;
// Wenn keine Breitenklasse in className, dann w-full als Standard
const hasWidthClass = /\bw-\d+\b|\bw-\[|\bflex-/.test(className);
return (
<div className={hasWidthClass ? className : 'w-full'}>
{label && (
<label htmlFor={selectId} className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
)}
<select
ref={ref}
id={selectId}
className={`block w-full px-3 py-2 border rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
error ? 'border-red-500' : 'border-gray-300'
}`}
{...props}
>
<option value="">{placeholder}</option>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
</div>
);
}
);
Select.displayName = 'Select';
export default Select;
+41
View File
@@ -0,0 +1,41 @@
import { ReactNode, useState } from 'react';
interface Tab {
id: string;
label: string;
content: ReactNode;
}
interface TabsProps {
tabs: Tab[];
defaultTab?: string;
}
export default function Tabs({ tabs, defaultTab }: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultTab || tabs[0]?.id);
return (
<div>
<div className="border-b border-gray-200">
<nav className="flex -mb-px space-x-8">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`py-4 px-1 border-b-2 font-medium text-sm whitespace-nowrap ${
activeTab === tab.id
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
<div className="mt-4">
{tabs.find((tab) => tab.id === activeTab)?.content}
</div>
</div>
);
}