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
+132
View File
@@ -0,0 +1,132 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from './context/AuthContext';
import ScrollToTop from './components/ScrollToTop';
import Layout from './components/layout/Layout';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import CustomerList from './pages/customers/CustomerList';
import CustomerDetail from './pages/customers/CustomerDetail';
import CustomerForm from './pages/customers/CustomerForm';
import ContractList from './pages/contracts/ContractList';
import ContractDetail from './pages/contracts/ContractDetail';
import ContractForm from './pages/contracts/ContractForm';
import ContractCockpit from './pages/contracts/ContractCockpit';
import TaskList from './pages/tasks/TaskList';
import PlatformList from './pages/platforms/PlatformList';
import CancellationPeriodList from './pages/settings/CancellationPeriodList';
import ContractDurationList from './pages/settings/ContractDurationList';
import ProviderList from './pages/settings/ProviderList';
import ContractCategoryList from './pages/settings/ContractCategoryList';
import ViewSettings from './pages/settings/ViewSettings';
import PortalSettings from './pages/settings/PortalSettings';
import DeadlineSettings from './pages/settings/DeadlineSettings';
import EmailProviders from './pages/settings/EmailProviders';
import UserList from './pages/users/UserList';
import Settings from './pages/Settings';
import DatabaseStructure from './pages/developer/DatabaseStructure';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Laden...</div>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
function DeveloperRoute({ children }: { children: React.ReactNode }) {
const { hasPermission, developerMode } = useAuth();
// Require both developer permission AND developer mode enabled
if (!hasPermission('developer:access') || !developerMode) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
}
function App() {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Laden...</div>
</div>
);
}
return (
<>
<ScrollToTop />
<Routes>
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
/>
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<Dashboard />} />
{/* Customers */}
<Route path="customers" element={<CustomerList />} />
<Route path="customers/new" element={<CustomerForm />} />
<Route path="customers/:id" element={<CustomerDetail />} />
<Route path="customers/:id/edit" element={<CustomerForm />} />
{/* Contracts */}
<Route path="contracts" element={<ContractList />} />
<Route path="contracts/cockpit" element={<ContractCockpit />} />
<Route path="contracts/new" element={<ContractForm />} />
<Route path="contracts/:id" element={<ContractDetail />} />
<Route path="contracts/:id/edit" element={<ContractForm />} />
{/* Tasks / Support-Anfragen */}
<Route path="tasks" element={<TaskList />} />
{/* Settings */}
<Route path="settings" element={<Settings />} />
<Route path="settings/users" element={<UserList />} />
<Route path="settings/platforms" element={<PlatformList />} />
<Route path="settings/cancellation-periods" element={<CancellationPeriodList />} />
<Route path="settings/contract-durations" element={<ContractDurationList />} />
<Route path="settings/providers" element={<ProviderList />} />
<Route path="settings/contract-categories" element={<ContractCategoryList />} />
<Route path="settings/view" element={<ViewSettings />} />
<Route path="settings/portal" element={<PortalSettings />} />
<Route path="settings/deadlines" element={<DeadlineSettings />} />
<Route path="settings/email-providers" element={<EmailProviders />} />
{/* Redirect old users route */}
<Route path="users" element={<Navigate to="/settings/users" replace />} />
{/* Redirect old platforms route */}
<Route path="platforms" element={<Navigate to="/settings/platforms" replace />} />
{/* Developer */}
<Route path="developer/database" element={<DeveloperRoute><DatabaseStructure /></DeveloperRoute>} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</>
);
}
export default App;
+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>
);
}
@@ -0,0 +1,54 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
interface AppSettings {
scrollToTopThreshold: number; // 0.5 = 50%, 0.7 = 70%, etc.
}
interface AppSettingsContextType {
settings: AppSettings;
updateSettings: (newSettings: Partial<AppSettings>) => void;
}
const defaultSettings: AppSettings = {
scrollToTopThreshold: 0.7,
};
const AppSettingsContext = createContext<AppSettingsContextType | undefined>(undefined);
const STORAGE_KEY = 'opencrm_app_settings';
export function AppSettingsProvider({ children }: { children: ReactNode }) {
const [settings, setSettings] = useState<AppSettings>(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
return { ...defaultSettings, ...JSON.parse(stored) };
} catch {
return defaultSettings;
}
}
return defaultSettings;
});
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}, [settings]);
const updateSettings = (newSettings: Partial<AppSettings>) => {
setSettings(prev => ({ ...prev, ...newSettings }));
};
return (
<AppSettingsContext.Provider value={{ settings, updateSettings }}>
{children}
</AppSettingsContext.Provider>
);
}
export function useAppSettings() {
const context = useContext(AppSettingsContext);
if (!context) {
throw new Error('useAppSettings must be used within AppSettingsProvider');
}
return context;
}
+144
View File
@@ -0,0 +1,144 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { authApi } from '../services/api';
import type { User } from '../types';
interface AuthContextType {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
customerLogin: (email: string, password: string) => Promise<void>;
logout: () => void;
hasPermission: (permission: string) => boolean;
isCustomer: boolean;
isCustomerPortal: boolean;
developerMode: boolean;
setDeveloperMode: (enabled: boolean) => void;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [developerMode, setDeveloperModeState] = useState(() => {
return localStorage.getItem('developerMode') === 'true';
});
const setDeveloperMode = (enabled: boolean) => {
setDeveloperModeState(enabled);
localStorage.setItem('developerMode', String(enabled));
};
// Disable developer mode if user doesn't have developer:access permission
useEffect(() => {
console.log('useEffect check - user:', user?.email, 'developerMode:', developerMode, 'has developer:access:', user?.permissions?.includes('developer:access'));
if (user && developerMode && !user.permissions.includes('developer:access')) {
console.log('Disabling developer mode because user lacks developer:access permission');
setDeveloperMode(false);
}
}, [user, developerMode]);
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
authApi.me()
.then((res) => {
if (res.success && res.data) {
setUser(res.data);
} else {
localStorage.removeItem('token');
}
})
.catch(() => {
localStorage.removeItem('token');
})
.finally(() => {
setIsLoading(false);
});
} else {
setIsLoading(false);
}
}, []);
const login = async (email: string, password: string) => {
const res = await authApi.login(email, password);
if (res.success && res.data) {
localStorage.setItem('token', res.data.token);
setUser(res.data.user);
} else {
throw new Error(res.error || 'Login fehlgeschlagen');
}
};
const customerLogin = async (email: string, password: string) => {
const res = await authApi.customerLogin(email, password);
if (res.success && res.data) {
localStorage.setItem('token', res.data.token);
setUser(res.data.user);
} else {
throw new Error(res.error || 'Login fehlgeschlagen');
}
};
const logout = () => {
localStorage.removeItem('token');
setUser(null);
};
const refreshUser = async () => {
const token = localStorage.getItem('token');
if (token) {
try {
const res = await authApi.me();
console.log('refreshUser response:', res);
console.log('permissions:', res.data?.permissions);
if (res.success && res.data) {
setUser(res.data);
}
} catch (err) {
console.error('refreshUser error:', err);
}
}
};
const hasPermission = (permission: string) => {
if (!user) return false;
return user.permissions.includes(permission);
};
// Ist der Benutzer ein Kunde (entweder Mitarbeiter mit Kundenzuordnung ODER Kundenportal-Login)
const isCustomer = !!(user?.customerId);
// Ist dies ein Kundenportal-Login (nicht ein Mitarbeiter mit Kundenzuordnung)
const isCustomerPortal = !!(user?.isCustomerPortal);
return (
<AuthContext.Provider
value={{
user,
isLoading,
isAuthenticated: !!user,
login,
customerLogin,
logout,
hasPermission,
isCustomer,
isCustomerPortal,
developerMode,
setDeveloperMode,
refreshUser,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
+7
View File
@@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-gray-100 text-gray-900;
}
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './context/AuthContext';
import { AppSettingsProvider } from './context/AppSettingsContext';
import App from './App';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 0, // Daten sind sofort "veraltet" → immer neu laden
gcTime: 0, // Kein Cache für unbenutzte Daten
refetchOnMount: 'always', // Immer neu laden wenn Komponente gemountet wird
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AppSettingsProvider>
<AuthProvider>
<App />
</AuthProvider>
</AppSettingsProvider>
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
);
+626
View File
@@ -0,0 +1,626 @@
import { useState, useMemo } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { customerApi, contractApi, contractTaskApi, appSettingsApi } from '../services/api';
import Card from '../components/ui/Card';
import Button from '../components/ui/Button';
import Input from '../components/ui/Input';
import Modal from '../components/ui/Modal';
import {
Users,
FileText,
AlertCircle,
AlertTriangle,
CheckCircle,
User,
ClipboardList,
MessageSquare,
Plus,
Clock,
XCircle,
} from 'lucide-react';
import type { Contract } from '../types';
export default function Dashboard() {
const { user, isCustomer, isCustomerPortal } = useAuth();
const [showCreateTicketModal, setShowCreateTicketModal] = useState(false);
// Lade öffentliche Einstellungen (für Kundenportal - Support-Tickets aktiviert?)
const { data: publicSettings, isLoading: isLoadingSettings } = useQuery({
queryKey: ['app-settings-public'],
queryFn: () => appSettingsApi.getPublic(),
enabled: isCustomerPortal,
staleTime: 0, // Immer neu laden, damit Einstellungsänderungen sofort wirken
});
// Wichtig: Nur true wenn explizit aktiviert UND geladen
const supportTicketsEnabled = !isLoadingSettings && publicSettings?.data?.customerSupportTicketsEnabled === 'true';
const { data: customersData } = useQuery({
queryKey: ['customers-count'],
queryFn: () => customerApi.getAll({ limit: 1 }),
enabled: !isCustomer,
});
const { data: contractsData } = useQuery({
queryKey: ['contracts', isCustomer ? user?.customerId : undefined],
queryFn: () => contractApi.getAll(isCustomer ? { customerId: user?.customerId } : { limit: 1 }),
});
const { data: activeContractsData } = useQuery({
queryKey: ['contracts-active', isCustomer ? user?.customerId : undefined],
queryFn: () => contractApi.getAll({
status: 'ACTIVE',
...(isCustomer ? { customerId: user?.customerId } : { limit: 1 }),
}),
});
const { data: pendingContractsData } = useQuery({
queryKey: ['contracts-pending', isCustomer ? user?.customerId : undefined],
queryFn: () => contractApi.getAll({
status: 'PENDING',
...(isCustomer ? { customerId: user?.customerId } : { limit: 1 }),
}),
});
// Task-Statistik
const { data: taskStatsData } = useQuery({
queryKey: ['task-stats'],
queryFn: () => contractTaskApi.getStats(),
});
// Vertrags-Cockpit für Mitarbeiter/Admins
const { data: cockpitData } = useQuery({
queryKey: ['contract-cockpit'],
queryFn: () => contractApi.getCockpit(),
enabled: !isCustomer,
staleTime: 0,
});
// Für Kundenportal: Verträge nach eigene/fremd gruppieren
const { ownContracts, representedContracts } = useMemo(() => {
if (!isCustomerPortal || !contractsData?.data) {
return { ownContracts: [], representedContracts: [] };
}
const own: Contract[] = [];
const represented: Record<number, { customerName: string; contracts: Contract[] }> = {};
for (const contract of contractsData.data) {
if (contract.customerId === user?.customerId) {
own.push(contract);
} else {
const customerId = contract.customerId;
if (!represented[customerId]) {
const customerName = contract.customer
? (contract.customer.companyName || `${contract.customer.firstName} ${contract.customer.lastName}`)
: `Kunde ${customerId}`;
represented[customerId] = { customerName, contracts: [] };
}
represented[customerId].contracts.push(contract);
}
}
return {
ownContracts: own,
representedContracts: Object.values(represented).sort((a, b) =>
a.customerName.localeCompare(b.customerName)
),
};
}, [contractsData?.data, isCustomerPortal, user?.customerId]);
// Zähle Verträge für eigene vs. fremd
const ownActiveCount = useMemo(() =>
ownContracts.filter(c => c.status === 'ACTIVE').length,
[ownContracts]
);
const ownPendingCount = useMemo(() =>
ownContracts.filter(c => c.status === 'PENDING').length,
[ownContracts]
);
const ownExpiredCount = useMemo(() =>
ownContracts.filter(c => c.status === 'EXPIRED').length,
[ownContracts]
);
const representedTotalCount = useMemo(() =>
representedContracts.reduce((sum, g) => sum + g.contracts.length, 0),
[representedContracts]
);
const representedActiveCount = useMemo(() =>
representedContracts.reduce((sum, g) => sum + g.contracts.filter(c => c.status === 'ACTIVE').length, 0),
[representedContracts]
);
const representedExpiredCount = useMemo(() =>
representedContracts.reduce((sum, g) => sum + g.contracts.filter(c => c.status === 'EXPIRED').length, 0),
[representedContracts]
);
const openTasksCount = taskStatsData?.data?.openCount || 0;
// Helper zum Rendern einer klickbaren Stat-Karte
const renderStatCard = (stat: {
label: string;
value: number;
icon: typeof FileText;
color: string;
link?: string;
}) => (
<Card key={stat.label} className={stat.link ? 'cursor-pointer hover:shadow-md transition-shadow' : ''}>
{stat.link ? (
<Link to={stat.link} className="block">
<div className="flex items-center">
<div className={`p-3 rounded-lg ${stat.color}`}>
<stat.icon className="w-6 h-6 text-white" />
</div>
<div className="ml-4">
<p className="text-sm text-gray-500">{stat.label}</p>
<p className="text-2xl font-bold">{stat.value}</p>
</div>
</div>
</Link>
) : (
<div className="flex items-center">
<div className={`p-3 rounded-lg ${stat.color}`}>
<stat.icon className="w-6 h-6 text-white" />
</div>
<div className="ml-4">
<p className="text-sm text-gray-500">{stat.label}</p>
<p className="text-2xl font-bold">{stat.value}</p>
</div>
</div>
)}
</Card>
);
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">
Willkommen, {user?.firstName}!
</h1>
{/* Support-Ticket erstellen Button für Kundenportal */}
{isCustomerPortal && supportTicketsEnabled && (
<Button onClick={() => setShowCreateTicketModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Support-Anfrage
</Button>
)}
</div>
{/* Kundenportal: Getrennte Statistiken */}
{isCustomerPortal ? (
<>
{/* Eigene Verträge Stats */}
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<User className="w-5 h-5 text-blue-600" />
<h2 className="text-lg font-semibold">Meine Verträge</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{renderStatCard({
label: 'Eigene Verträge',
value: ownContracts.length,
icon: FileText,
color: 'bg-blue-500',
link: '/contracts',
})}
{renderStatCard({
label: 'Davon aktiv',
value: ownActiveCount,
icon: CheckCircle,
color: 'bg-green-500',
})}
{renderStatCard({
label: 'Davon ausstehend',
value: ownPendingCount,
icon: Clock,
color: 'bg-yellow-500',
})}
{renderStatCard({
label: 'Davon abgelaufen',
value: ownExpiredCount,
icon: XCircle,
color: 'bg-red-500',
})}
</div>
</div>
{/* Fremdverträge Stats - nur anzeigen wenn vorhanden */}
{representedTotalCount > 0 && (
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<Users className="w-5 h-5 text-purple-600" />
<h2 className="text-lg font-semibold">Fremdverträge</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{renderStatCard({
label: 'Fremdverträge',
value: representedTotalCount,
icon: Users,
color: 'bg-purple-500',
link: '/contracts',
})}
{renderStatCard({
label: 'Davon aktiv',
value: representedActiveCount,
icon: CheckCircle,
color: 'bg-green-500',
})}
{/* Leere Karte für Symmetrie */}
<div className="hidden lg:block"></div>
{renderStatCard({
label: 'Davon abgelaufen',
value: representedExpiredCount,
icon: XCircle,
color: 'bg-red-500',
})}
</div>
</div>
)}
{/* Support-Anfragen Stats */}
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<MessageSquare className="w-5 h-5 text-orange-600" />
<h2 className="text-lg font-semibold">Support-Anfragen</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{renderStatCard({
label: 'Offene Anfragen',
value: openTasksCount,
icon: MessageSquare,
color: 'bg-orange-500',
link: '/tasks',
})}
</div>
</div>
</>
) : (
/* Mitarbeiter/Admin: Standard Stats */
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
{renderStatCard({
label: 'Kunden',
value: customersData?.pagination?.total || 0,
icon: Users,
color: 'bg-blue-500',
link: '/customers',
})}
{renderStatCard({
label: 'Verträge gesamt',
value: contractsData?.pagination?.total || 0,
icon: FileText,
color: 'bg-purple-500',
link: '/contracts',
})}
{renderStatCard({
label: 'Aktive Verträge',
value: activeContractsData?.pagination?.total || 0,
icon: CheckCircle,
color: 'bg-green-500',
})}
{renderStatCard({
label: 'Ausstehende Verträge',
value: pendingContractsData?.pagination?.total || 0,
icon: AlertCircle,
color: 'bg-yellow-500',
})}
</div>
{/* Vertrags-Cockpit Übersicht */}
{cockpitData?.data && (
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-red-500" />
<h2 className="text-lg font-semibold">Vertrags-Cockpit</h2>
</div>
<Link to="/contracts/cockpit" className="text-sm text-blue-600 hover:underline">
Alle anzeigen
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="cursor-pointer hover:shadow-md transition-shadow">
<Link to="/contracts/cockpit?filter=critical" className="block">
<div className="flex items-center">
<div className="p-3 rounded-lg bg-red-100">
<AlertCircle className="w-6 h-6 text-red-500" />
</div>
<div className="ml-4">
<p className="text-sm text-gray-500">Kritisch (&lt;{cockpitData.data.thresholds.criticalDays} Tage)</p>
<p className="text-2xl font-bold text-red-600">{cockpitData.data.summary.criticalCount}</p>
</div>
</div>
</Link>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow">
<Link to="/contracts/cockpit?filter=warning" className="block">
<div className="flex items-center">
<div className="p-3 rounded-lg bg-yellow-100">
<AlertTriangle className="w-6 h-6 text-yellow-500" />
</div>
<div className="ml-4">
<p className="text-sm text-gray-500">Warnung (&lt;{cockpitData.data.thresholds.warningDays} Tage)</p>
<p className="text-2xl font-bold text-yellow-600">{cockpitData.data.summary.warningCount}</p>
</div>
</div>
</Link>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow">
<Link to="/contracts/cockpit?filter=ok" className="block">
<div className="flex items-center">
<div className="p-3 rounded-lg bg-green-100">
<CheckCircle className="w-6 h-6 text-green-500" />
</div>
<div className="ml-4">
<p className="text-sm text-gray-500">OK (&lt;{cockpitData.data.thresholds.okDays} Tage)</p>
<p className="text-2xl font-bold text-green-600">{cockpitData.data.summary.okCount}</p>
</div>
</div>
</Link>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow">
<Link to="/contracts/cockpit" className="block">
<div className="flex items-center">
<div className="p-3 rounded-lg bg-gray-100">
<FileText className="w-6 h-6 text-gray-500" />
</div>
<div className="ml-4">
<p className="text-sm text-gray-500">Handlungsbedarf</p>
<p className="text-2xl font-bold text-gray-600">{cockpitData.data.summary.totalContracts}</p>
</div>
</div>
</Link>
</Card>
</div>
</div>
)}
{/* Aufgaben Stats für Mitarbeiter */}
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<ClipboardList className="w-5 h-5 text-orange-600" />
<h2 className="text-lg font-semibold">Aufgaben</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{renderStatCard({
label: 'Offene Aufgaben',
value: openTasksCount,
icon: ClipboardList,
color: 'bg-orange-500',
link: '/tasks',
})}
</div>
</div>
</>
)}
{/* Support-Ticket erstellen Modal (für Kundenportal) */}
{isCustomerPortal && (
<CreateSupportTicketModal
isOpen={showCreateTicketModal}
onClose={() => setShowCreateTicketModal(false)}
/>
)}
</div>
);
}
// Modal für neue Support-Anfrage (Kundenportal)
function CreateSupportTicketModal({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) {
const { user } = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [customerFilter, setCustomerFilter] = useState<'own' | number>('own');
const [selectedContractId, setSelectedContractId] = useState<number | null>(null);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [contractSearch, setContractSearch] = useState('');
// Lade alle Verträge des Benutzers (eigene + freigegebene)
const { data: contractsData } = useQuery({
queryKey: ['contracts', user?.customerId],
queryFn: () => contractApi.getAll({ customerId: user?.customerId }),
enabled: isOpen,
});
// Gruppiere Verträge nach Kunde
const groupedContracts = useMemo(() => {
if (!contractsData?.data) return { own: [], represented: {} as Record<number, { name: string; contracts: Contract[] }> };
const own: Contract[] = [];
const represented: Record<number, { name: string; contracts: Contract[] }> = {};
for (const contract of contractsData.data) {
if (contract.customerId === user?.customerId) {
own.push(contract);
} else {
if (!represented[contract.customerId]) {
const name = contract.customer
? (contract.customer.companyName || `${contract.customer.firstName} ${contract.customer.lastName}`)
: `Kunde ${contract.customerId}`;
represented[contract.customerId] = { name, contracts: [] };
}
represented[contract.customerId].contracts.push(contract);
}
}
return { own, represented };
}, [contractsData?.data, user?.customerId]);
// Hat der Benutzer freigegebene Kunden?
const hasRepresentedCustomers = Object.keys(groupedContracts.represented).length > 0;
// Aktuelle Verträge basierend auf Kundenfilter
const currentContracts = useMemo(() => {
if (customerFilter === 'own') {
return groupedContracts.own;
}
return groupedContracts.represented[customerFilter]?.contracts || [];
}, [customerFilter, groupedContracts]);
// Gefilterte Verträge basierend auf Suche
const filteredContracts = useMemo(() => {
if (!contractSearch) return currentContracts;
const search = contractSearch.toLowerCase();
return currentContracts.filter(c =>
c.contractNumber.toLowerCase().includes(search) ||
(c.providerName || '').toLowerCase().includes(search) ||
(c.tariffName || '').toLowerCase().includes(search)
);
}, [currentContracts, contractSearch]);
const handleSubmit = async () => {
if (!selectedContractId || !title.trim()) return;
setIsSubmitting(true);
try {
await contractTaskApi.createSupportTicket(selectedContractId, {
title: title.trim(),
description: description.trim() || undefined,
});
// Invalidate task stats
queryClient.invalidateQueries({ queryKey: ['task-stats'] });
queryClient.invalidateQueries({ queryKey: ['all-tasks'] });
onClose();
// Reset form
setTitle('');
setDescription('');
setSelectedContractId(null);
setCustomerFilter('own');
// Navigate to the contract
navigate(`/contracts/${selectedContractId}`);
} catch (error) {
console.error('Fehler beim Erstellen der Support-Anfrage:', error);
alert('Fehler beim Erstellen der Support-Anfrage. Bitte versuchen Sie es erneut.');
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
setTitle('');
setDescription('');
setSelectedContractId(null);
setCustomerFilter('own');
setContractSearch('');
onClose();
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Neue Support-Anfrage"
>
<div className="space-y-4">
{/* Kundenauswahl (nur wenn freigegebene Kunden vorhanden) */}
{hasRepresentedCustomers && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kunde
</label>
<select
value={customerFilter}
onChange={(e) => {
const val = e.target.value;
setCustomerFilter(val === 'own' ? 'own' : parseInt(val));
setSelectedContractId(null);
setContractSearch('');
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="own">Eigene Verträge</option>
{Object.entries(groupedContracts.represented).map(([id, { name }]) => (
<option key={id} value={id}>{name}</option>
))}
</select>
</div>
)}
{/* Vertragsauswahl */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Vertrag *
</label>
<Input
placeholder="Vertrag suchen..."
value={contractSearch}
onChange={(e) => setContractSearch(e.target.value)}
className="mb-2"
/>
<div className="max-h-48 overflow-y-auto border rounded-lg">
{filteredContracts.length > 0 ? (
filteredContracts.map((contract) => (
<div
key={contract.id}
onClick={() => setSelectedContractId(contract.id)}
className={`p-3 cursor-pointer border-b last:border-b-0 hover:bg-gray-50 ${
selectedContractId === contract.id ? 'bg-blue-50 border-blue-200' : ''
}`}
>
<div className="font-medium">{contract.contractNumber}</div>
<div className="text-sm text-gray-500">
{contract.providerName || 'Kein Anbieter'}
{contract.tariffName && ` - ${contract.tariffName}`}
</div>
</div>
))
) : (
<div className="p-3 text-gray-500 text-center">
Keine Verträge gefunden.
</div>
)}
</div>
</div>
{/* Titel */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Titel *
</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Kurze Beschreibung Ihres Anliegens"
/>
</div>
{/* Beschreibung */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Beschreibung
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Detaillierte Beschreibung (optional)"
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Buttons */}
<div className="flex justify-end gap-2 pt-4">
<Button variant="secondary" onClick={handleClose}>
Abbrechen
</Button>
<Button
onClick={handleSubmit}
disabled={!selectedContractId || !title.trim() || isSubmitting}
>
{isSubmitting ? 'Wird erstellt...' : 'Anfrage erstellen'}
</Button>
</div>
</div>
</Modal>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import Button from '../components/ui/Button';
import Input from '../components/ui/Input';
import Card from '../components/ui/Card';
export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login, customerLogin } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(''); // Fehler nur beim Klick auf Login löschen
setIsLoading(true);
try {
// Erst Mitarbeiter-Login versuchen
await login(email, password);
navigate('/');
return;
} catch {
// Mitarbeiter-Login fehlgeschlagen, versuche Kunden-Login
}
try {
await customerLogin(email, password);
navigate('/');
} catch {
// Beide fehlgeschlagen
setError('Ungültige Anmeldedaten');
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<Card className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">OpenCRM</h1>
<p className="text-gray-600 mt-2">Melden Sie sich an</p>
</div>
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="E-Mail"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
<Input
label="Passwort"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Anmeldung...' : 'Anmelden'}
</Button>
</form>
</Card>
</div>
);
}
+221
View File
@@ -0,0 +1,221 @@
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import Card from '../components/ui/Card';
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail } from 'lucide-react';
export default function Settings() {
const { hasPermission, developerMode, setDeveloperMode } = useAuth();
const settingsCards = [
{
to: '/settings/users',
icon: UserCog,
title: 'Benutzer',
description: 'Verwalten Sie Benutzerkonten, Rollen und Berechtigungen.',
show: hasPermission('users:read'),
},
{
to: '/settings/platforms',
icon: Store,
title: 'Vertriebsplattformen',
description: 'Verwalten Sie die Plattformen, über die Verträge abgeschlossen werden.',
show: hasPermission('platforms:read'),
},
{
to: '/settings/cancellation-periods',
icon: Clock,
title: 'Kündigungsfristen',
description: 'Konfigurieren Sie die verfügbaren Kündigungsfristen für Verträge.',
show: hasPermission('platforms:read'),
},
{
to: '/settings/contract-durations',
icon: Calendar,
title: 'Vertragslaufzeiten',
description: 'Konfigurieren Sie die verfügbaren Laufzeiten für Verträge.',
show: hasPermission('platforms:read'),
},
{
to: '/settings/providers',
icon: Building2,
title: 'Anbieter & Tarife',
description: 'Verwalten Sie Anbieter und deren Tarife für Verträge.',
show: hasPermission('providers:read') || hasPermission('platforms:read'),
},
{
to: '/settings/contract-categories',
icon: FileType,
title: 'Vertragstypen',
description: 'Konfigurieren Sie die verfügbaren Vertragstypen (Strom, Gas, Mobilfunk, etc.).',
show: hasPermission('platforms:read'),
},
];
return (
<div>
<div className="flex items-center gap-3 mb-6">
<SettingsIcon className="w-6 h-6" />
<h1 className="text-2xl font-bold">Einstellungen</h1>
</div>
{/* Stammdaten-Konfiguration */}
<div className="mb-8">
<h2 className="text-lg font-semibold mb-4 text-gray-700">Stammdaten</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{settingsCards
.filter((card) => card.show)
.map((card) => (
<Link
key={card.to}
to={card.to}
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
>
<div className="flex items-start gap-4">
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
<card.icon className="w-6 h-6 text-blue-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
{card.title}
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</h3>
<p className="text-sm text-gray-500 mt-1">{card.description}</p>
</div>
</div>
</Link>
))}
</div>
</div>
{/* System-Einstellungen */}
{hasPermission('settings:update') && (
<div className="mb-8">
<h2 className="text-lg font-semibold mb-4 text-gray-700">System</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Link
to="/settings/portal"
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
>
<div className="flex items-start gap-4">
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
<Globe className="w-6 h-6 text-blue-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
Kundenportal
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</h3>
<p className="text-sm text-gray-500 mt-1">Konfigurieren Sie das Kundenportal und Support-Anfragen.</p>
</div>
</div>
</Link>
<Link
to="/settings/deadlines"
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
>
<div className="flex items-start gap-4">
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
<Clock className="w-6 h-6 text-blue-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
Fristenschwellen
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</h3>
<p className="text-sm text-gray-500 mt-1">Konfigurieren Sie die Farbkodierung für Vertragsfristen im Cockpit.</p>
</div>
</div>
</Link>
<Link
to="/settings/email-providers"
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
>
<div className="flex items-start gap-4">
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
<Mail className="w-6 h-6 text-blue-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
Email-Provisionierung
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</h3>
<p className="text-sm text-gray-500 mt-1">Konfigurieren Sie die automatische E-Mail-Erstellung für Stressfrei-Wechseln Adressen.</p>
</div>
</div>
</Link>
</div>
</div>
)}
{/* Persönliche Einstellungen */}
<div className="mb-8">
<h2 className="text-lg font-semibold mb-4 text-gray-700">Persönlich</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Link
to="/settings/view"
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
>
<div className="flex items-start gap-4">
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
<Eye className="w-6 h-6 text-blue-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
Ansicht
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</h3>
<p className="text-sm text-gray-500 mt-1">Passen Sie die Darstellung der Anwendung an.</p>
</div>
</div>
</Link>
</div>
</div>
{hasPermission('developer:access') && (
<Card title="Entwickleroptionen" className="mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Code className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Entwicklermodus</p>
<p className="text-sm text-gray-500">
Aktiviert erweiterte Funktionen wie direkten Datenbankzugriff
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={developerMode}
onChange={(e) => setDeveloperMode(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
{developerMode && (
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
<strong>Warnung:</strong> Der Entwicklermodus ermöglicht direkten Zugriff auf die Datenbank.
Unsachgemäße Änderungen können zu Datenverlust oder Inkonsistenzen führen.
</p>
</div>
)}
</Card>
)}
<Card title="Über">
<dl className="space-y-3">
<div>
<dt className="text-sm text-gray-500">Version</dt>
<dd>1.0.0</dd>
</div>
<div>
<dt className="text-sm text-gray-500">System</dt>
<dd>OpenCRM</dd>
</div>
</dl>
</Card>
</div>
);
}
@@ -0,0 +1,421 @@
import { useState, useMemo, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link, useSearchParams } from 'react-router-dom';
import { contractApi } from '../../services/api';
import Card from '../../components/ui/Card';
import Badge from '../../components/ui/Badge';
import Select from '../../components/ui/Select';
import {
AlertCircle,
AlertTriangle,
CheckCircle,
Clock,
Eye,
Calendar,
Key,
FileText,
ClipboardList,
ChevronDown,
ChevronRight,
Zap,
Wifi,
Smartphone,
Tv,
Car,
Flame,
} from 'lucide-react';
import type { CockpitContract, CockpitUrgencyLevel, ContractType } from '../../types';
const typeIcons: Record<ContractType, typeof Zap> = {
ELECTRICITY: Zap,
GAS: Flame,
DSL: Wifi,
CABLE: Wifi,
FIBER: Wifi,
MOBILE: Smartphone,
TV: Tv,
CAR_INSURANCE: Car,
};
const typeLabels: Record<ContractType, string> = {
ELECTRICITY: 'Strom',
GAS: 'Gas',
DSL: 'DSL',
CABLE: 'Kabel',
FIBER: 'Glasfaser',
MOBILE: 'Mobilfunk',
TV: 'TV',
CAR_INSURANCE: 'KFZ',
};
const urgencyColors: Record<CockpitUrgencyLevel, string> = {
critical: 'bg-red-100 border-red-300 text-red-800',
warning: 'bg-yellow-100 border-yellow-300 text-yellow-800',
ok: 'bg-green-100 border-green-300 text-green-800',
none: 'bg-gray-100 border-gray-300 text-gray-800',
};
const urgencyBadgeVariants: Record<CockpitUrgencyLevel, 'danger' | 'warning' | 'success' | 'default'> = {
critical: 'danger',
warning: 'warning',
ok: 'success',
none: 'default',
};
const issueTypeIcons: Record<string, typeof Calendar> = {
cancellation_deadline: Calendar,
contract_ending: Clock,
missing_cancellation_letter: FileText,
missing_cancellation_confirmation: FileText,
missing_portal_credentials: Key,
missing_customer_number: FileText,
missing_provider: FileText,
missing_address: FileText,
missing_bank: FileText,
missing_meter: Zap,
missing_sim: Smartphone,
open_tasks: ClipboardList,
pending_status: Clock,
draft_status: FileText,
};
const categoryLabels: Record<string, string> = {
cancellationDeadlines: 'Kündigungsfristen',
contractEnding: 'Vertragsenden',
missingCredentials: 'Fehlende Zugangsdaten',
missingData: 'Fehlende Daten',
openTasks: 'Offene Aufgaben',
pendingContracts: 'Wartende Verträge',
};
type FilterType = 'all' | 'critical' | 'warning' | 'ok' | 'deadlines' | 'credentials' | 'data' | 'tasks';
export default function ContractCockpit() {
const [searchParams, setSearchParams] = useSearchParams();
const [expandedContracts, setExpandedContracts] = useState<Set<number>>(new Set());
// Filter aus URL-Parameter initialisieren
const urlFilter = searchParams.get('filter') as FilterType | null;
const [filter, setFilter] = useState<FilterType>(urlFilter || 'all');
// URL-Parameter bei Filter-Änderung aktualisieren
useEffect(() => {
if (filter === 'all') {
searchParams.delete('filter');
} else {
searchParams.set('filter', filter);
}
setSearchParams(searchParams, { replace: true });
}, [filter, searchParams, setSearchParams]);
const { data: cockpitData, isLoading, error } = useQuery({
queryKey: ['contract-cockpit'],
queryFn: () => contractApi.getCockpit(),
staleTime: 0,
});
const toggleExpanded = (contractId: number) => {
setExpandedContracts(prev => {
const next = new Set(prev);
if (next.has(contractId)) {
next.delete(contractId);
} else {
next.add(contractId);
}
return next;
});
};
// Filter contracts
const filteredContracts = useMemo(() => {
if (!cockpitData?.data?.contracts) return [];
const contracts = cockpitData.data.contracts;
switch (filter) {
case 'critical':
return contracts.filter(c => c.highestUrgency === 'critical');
case 'warning':
return contracts.filter(c => c.highestUrgency === 'warning');
case 'ok':
return contracts.filter(c => c.highestUrgency === 'ok');
case 'deadlines':
return contracts.filter(c =>
c.issues.some(i => ['cancellation_deadline', 'contract_ending'].includes(i.type))
);
case 'credentials':
return contracts.filter(c =>
c.issues.some(i => i.type.includes('credentials'))
);
case 'data':
return contracts.filter(c =>
c.issues.some(i => i.type.startsWith('missing_') && !i.type.includes('credentials'))
);
case 'tasks':
return contracts.filter(c =>
c.issues.some(i => ['open_tasks', 'pending_status', 'draft_status'].includes(i.type))
);
default:
return contracts;
}
}, [cockpitData?.data?.contracts, filter]);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-gray-500">Laden...</div>
</div>
);
}
if (error || !cockpitData?.data) {
return (
<div className="text-center py-12">
<p className="text-red-500">Fehler beim Laden des Cockpits</p>
</div>
);
}
const { summary, thresholds } = cockpitData.data;
const renderContract = (contract: CockpitContract) => {
const isExpanded = expandedContracts.has(contract.id);
const TypeIcon = typeIcons[contract.type] || FileText;
return (
<div
key={contract.id}
className={`border rounded-lg mb-2 ${urgencyColors[contract.highestUrgency]}`}
>
{/* Contract Header */}
<div
className="flex items-center p-4 cursor-pointer hover:bg-opacity-50"
onClick={() => toggleExpanded(contract.id)}
>
{/* Expand Icon */}
<div className="w-6 mr-2">
{isExpanded ? (
<ChevronDown className="w-5 h-5" />
) : (
<ChevronRight className="w-5 h-5" />
)}
</div>
{/* Type Icon */}
<TypeIcon className="w-5 h-5 mr-3" />
{/* Contract Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Link
to={`/contracts/${contract.id}`}
state={{ from: 'cockpit', filter: filter !== 'all' ? filter : undefined }}
className="font-medium hover:underline"
onClick={(e) => e.stopPropagation()}
>
{contract.contractNumber}
</Link>
<Badge variant={urgencyBadgeVariants[contract.highestUrgency]}>
{contract.issues.length} {contract.highestUrgency === 'ok'
? (contract.issues.length === 1 ? 'Hinweis' : 'Hinweise')
: (contract.issues.length === 1 ? 'Problem' : 'Probleme')}
</Badge>
<span className="text-sm">
{typeLabels[contract.type]}
</span>
</div>
<div className="text-sm mt-1">
<Link
to={`/customers/${contract.customer.id}`}
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
{contract.customer.customerNumber} - {contract.customer.name}
</Link>
{(contract.provider?.name || contract.providerName) && (
<span className="ml-2">
| {contract.provider?.name || contract.providerName}
{(contract.tariff?.name || contract.tariffName) &&
` - ${contract.tariff?.name || contract.tariffName}`}
</span>
)}
</div>
</div>
{/* Actions */}
<Link
to={`/contracts/${contract.id}`}
state={{ from: 'cockpit', filter: filter !== 'all' ? filter : undefined }}
className="ml-4 p-2 hover:bg-white hover:bg-opacity-50 rounded"
onClick={(e) => e.stopPropagation()}
title="Zum Vertrag"
>
<Eye className="w-4 h-4" />
</Link>
</div>
{/* Expanded: Issues */}
{isExpanded && (
<div className="border-t px-4 py-3 bg-white bg-opacity-50">
<div className="space-y-2">
{contract.issues.map((issue, idx) => {
const IssueIcon = issueTypeIcons[issue.type] || AlertCircle;
const UrgencyIcon = issue.urgency === 'critical' ? AlertCircle :
issue.urgency === 'warning' ? AlertTriangle :
issue.urgency === 'ok' ? CheckCircle : Clock;
return (
<div
key={idx}
className="flex items-start gap-3 text-sm"
>
<UrgencyIcon className={`w-4 h-4 mt-0.5 flex-shrink-0 ${
issue.urgency === 'critical' ? 'text-red-500' :
issue.urgency === 'warning' ? 'text-yellow-500' :
issue.urgency === 'ok' ? 'text-green-500' : 'text-gray-500'
}`} />
<IssueIcon className="w-4 h-4 mt-0.5 flex-shrink-0 text-gray-500" />
<div>
<span className="font-medium">{issue.label}</span>
{issue.details && (
<span className="text-gray-600 ml-2">{issue.details}</span>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<AlertCircle className="w-6 h-6 text-red-500" />
<h1 className="text-2xl font-bold">Vertrags-Cockpit</h1>
</div>
<Link
to="/settings/deadlines"
className="text-sm text-blue-600 hover:underline"
>
Fristenschwellen anpassen
</Link>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card className="!p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-red-100 rounded-lg">
<AlertCircle className="w-6 h-6 text-red-500" />
</div>
<div>
<p className="text-2xl font-bold text-red-600">{summary.criticalCount}</p>
<p className="text-sm text-gray-500">Kritisch (&lt;{thresholds.criticalDays} Tage)</p>
</div>
</div>
</Card>
<Card className="!p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-yellow-100 rounded-lg">
<AlertTriangle className="w-6 h-6 text-yellow-500" />
</div>
<div>
<p className="text-2xl font-bold text-yellow-600">{summary.warningCount}</p>
<p className="text-sm text-gray-500">Warnung (&lt;{thresholds.warningDays} Tage)</p>
</div>
</div>
</Card>
<Card className="!p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 rounded-lg">
<CheckCircle className="w-6 h-6 text-green-500" />
</div>
<div>
<p className="text-2xl font-bold text-green-600">{summary.okCount}</p>
<p className="text-sm text-gray-500">OK (&lt;{thresholds.okDays} Tage)</p>
</div>
</div>
</Card>
<Card className="!p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-100 rounded-lg">
<FileText className="w-6 h-6 text-gray-500" />
</div>
<div>
<p className="text-2xl font-bold text-gray-600">{summary.totalContracts}</p>
<p className="text-sm text-gray-500">Verträge mit Handlungsbedarf</p>
</div>
</div>
</Card>
</div>
{/* Category Summary */}
<Card className="mb-6">
<div className="flex flex-wrap gap-4">
{Object.entries(summary.byCategory).map(([key, count]) => (
count > 0 && (
<div key={key} className="flex items-center gap-2 text-sm">
<span className="font-medium">{categoryLabels[key] || key}:</span>
<Badge variant="default">{count}</Badge>
</div>
)
))}
</div>
</Card>
{/* Filter */}
<Card className="mb-6">
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">Filter:</span>
<Select
value={filter}
onChange={(e) => setFilter(e.target.value as FilterType)}
options={[
{ value: 'all', label: `Alle (${cockpitData.data.contracts.length})` },
{ value: 'critical', label: `Kritisch (${summary.criticalCount})` },
{ value: 'warning', label: `Warnung (${summary.warningCount})` },
{ value: 'ok', label: `OK (${summary.okCount})` },
{ value: 'deadlines', label: `Fristen (${summary.byCategory.cancellationDeadlines + summary.byCategory.contractEnding})` },
{ value: 'credentials', label: `Zugangsdaten (${summary.byCategory.missingCredentials})` },
{ value: 'data', label: `Fehlende Daten (${summary.byCategory.missingData})` },
{ value: 'tasks', label: `Aufgaben/Status (${summary.byCategory.openTasks + summary.byCategory.pendingContracts})` },
]}
className="w-64"
/>
<span className="text-sm text-gray-500">
{filteredContracts.length} Verträge angezeigt
</span>
</div>
</Card>
{/* Contract List */}
{filteredContracts.length === 0 ? (
<Card>
<div className="text-center py-8 text-gray-500">
{filter === 'all' ? (
<>
<CheckCircle className="w-12 h-12 mx-auto mb-4 text-green-500" />
<p className="text-lg font-medium">Alles in Ordnung!</p>
<p>Keine Verträge mit Handlungsbedarf gefunden.</p>
</>
) : (
<p>Keine Verträge für diesen Filter gefunden.</p>
)}
</div>
</Card>
) : (
<div>
{filteredContracts.map(renderContract)}
</div>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,370 @@
import { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
import { contractApi } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
import Badge from '../../components/ui/Badge';
import { Plus, Search, Eye, Edit, Trash2, User, Users } from 'lucide-react';
import type { Contract, ContractType, ContractStatus } from '../../types';
const typeLabels: Record<ContractType, string> = {
ELECTRICITY: 'Strom',
GAS: 'Gas',
DSL: 'DSL',
CABLE: 'Kabelinternet',
FIBER: 'Glasfaser',
MOBILE: 'Mobilfunk',
TV: 'TV',
CAR_INSURANCE: 'KFZ-Versicherung',
};
const statusLabels: Record<ContractStatus, string> = {
DRAFT: 'Entwurf',
PENDING: 'Ausstehend',
ACTIVE: 'Aktiv',
CANCELLED: 'Gekündigt',
EXPIRED: 'Abgelaufen',
DEACTIVATED: 'Deaktiviert',
};
const statusVariants: Record<ContractStatus, 'success' | 'warning' | 'danger' | 'default'> = {
ACTIVE: 'success',
PENDING: 'warning',
CANCELLED: 'danger',
EXPIRED: 'danger',
DRAFT: 'default',
DEACTIVATED: 'default',
};
export default function ContractList() {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
// Filter-Werte aus URL-Parametern lesen
const [search, setSearch] = useState(searchParams.get('search') || '');
const [type, setType] = useState(searchParams.get('type') || '');
const [status, setStatus] = useState(searchParams.get('status') || '');
const [page, setPage] = useState(parseInt(searchParams.get('page') || '1', 10));
const { hasPermission, isCustomer, isCustomerPortal, user } = useAuth();
const queryClient = useQueryClient();
// URL-Parameter aktualisieren wenn sich Filter ändern
useEffect(() => {
const params = new URLSearchParams();
if (search) params.set('search', search);
if (type) params.set('type', type);
if (status) params.set('status', status);
if (page > 1) params.set('page', page.toString());
setSearchParams(params, { replace: true });
}, [search, type, status, page, setSearchParams]);
const deleteMutation = useMutation({
mutationFn: contractApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contracts'] });
},
});
const { data, isLoading } = useQuery({
queryKey: ['contracts', search, type, status, page, isCustomer ? user?.customerId : null],
queryFn: () =>
contractApi.getAll({
search: search || undefined,
type: type || undefined,
status: status || undefined,
page,
limit: 20,
customerId: isCustomer ? user?.customerId : undefined,
}),
});
// Für Kundenportal: Verträge nach Kunde gruppieren
const groupedContracts = useMemo(() => {
if (!isCustomerPortal || !data?.data) return null;
const groups: Record<number, { customerName: string; isOwn: boolean; contracts: Contract[] }> = {};
for (const contract of data.data) {
const customerId = contract.customerId;
if (!groups[customerId]) {
const customerName = contract.customer
? (contract.customer.companyName || `${contract.customer.firstName} ${contract.customer.lastName}`)
: `Kunde ${customerId}`;
groups[customerId] = {
customerName,
isOwn: customerId === user?.customerId,
contracts: [],
};
}
groups[customerId].contracts.push(contract);
}
// Eigene Verträge zuerst, dann alphabetisch nach Name
return Object.values(groups).sort((a, b) => {
if (a.isOwn && !b.isOwn) return -1;
if (!a.isOwn && b.isOwn) return 1;
return a.customerName.localeCompare(b.customerName);
});
}, [data?.data, isCustomerPortal, user?.customerId]);
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Verträge</h1>
{hasPermission('contracts:create') && !isCustomer && (
<Link to="/contracts/new">
<Button>
<Plus className="w-4 h-4 mr-2" />
Neuer Vertrag
</Button>
</Link>
)}
</div>
<Card className="mb-6">
<div className="flex gap-4 flex-wrap">
<div className="flex-1 min-w-[200px]">
<Input
placeholder="Suchen..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Select
value={type}
onChange={(e) => setType(e.target.value)}
options={Object.entries(typeLabels).map(([value, label]) => ({ value, label }))}
className="w-48"
/>
<Select
value={status}
onChange={(e) => setStatus(e.target.value)}
options={Object.entries(statusLabels).map(([value, label]) => ({ value, label }))}
className="w-48"
/>
<Button variant="secondary">
<Search className="w-4 h-4" />
</Button>
</div>
</Card>
{isLoading ? (
<Card>
<div className="text-center py-8 text-gray-500">Laden...</div>
</Card>
) : data?.data && data.data.length > 0 ? (
<>
{/* Kundenportal: Gruppierte Ansicht */}
{isCustomerPortal && groupedContracts ? (
<div className="space-y-6">
{groupedContracts.map((group) => (
<Card key={group.isOwn ? 'own' : group.customerName}>
{/* Gruppen-Header */}
<div className="flex items-center gap-3 mb-4 pb-3 border-b">
{group.isOwn ? (
<>
<User className="w-5 h-5 text-blue-600" />
<h2 className="text-lg font-semibold text-gray-900">Meine Verträge</h2>
<Badge variant="default">{group.contracts.length}</Badge>
</>
) : (
<>
<Users className="w-5 h-5 text-purple-600" />
<h2 className="text-lg font-semibold text-gray-900">
Verträge von {group.customerName}
</h2>
<Badge variant="default">{group.contracts.length}</Badge>
</>
)}
</div>
{/* Vertrags-Tabelle */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4 font-medium text-gray-600">Vertragsnr.</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Typ</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Anbieter / Tarif</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Status</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Beginn</th>
<th className="text-right py-3 px-4 font-medium text-gray-600">Aktionen</th>
</tr>
</thead>
<tbody>
{group.contracts.map((contract) => (
<tr key={contract.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4 font-mono text-sm">{contract.contractNumber}</td>
<td className="py-3 px-4">
<Badge>{typeLabels[contract.type]}</Badge>
</td>
<td className="py-3 px-4">
{contract.providerName || '-'}
{contract.tariffName && (
<span className="text-gray-500"> / {contract.tariffName}</span>
)}
</td>
<td className="py-3 px-4">
<Badge variant={statusVariants[contract.status]}>
{statusLabels[contract.status]}
</Badge>
</td>
<td className="py-3 px-4">
{contract.startDate
? new Date(contract.startDate).toLocaleDateString('de-DE')
: '-'}
</td>
<td className="py-3 px-4 text-right">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/contracts/${contract.id}`, {
state: { from: 'contracts' }
})}
>
<Eye className="w-4 h-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
))}
</div>
) : (
/* Standard-Ansicht für Mitarbeiter */
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4 font-medium text-gray-600">Vertragsnr.</th>
{!isCustomer && (
<th className="text-left py-3 px-4 font-medium text-gray-600">Kunde</th>
)}
<th className="text-left py-3 px-4 font-medium text-gray-600">Typ</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Anbieter / Tarif</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Status</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Beginn</th>
<th className="text-right py-3 px-4 font-medium text-gray-600">Aktionen</th>
</tr>
</thead>
<tbody>
{data.data.map((contract) => (
<tr key={contract.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4 font-mono text-sm">{contract.contractNumber}</td>
{!isCustomer && (
<td className="py-3 px-4">
{contract.customer && (
<Link
to={`/customers/${contract.customer.id}`}
className="text-blue-600 hover:underline"
>
{contract.customer.companyName ||
`${contract.customer.firstName} ${contract.customer.lastName}`}
</Link>
)}
</td>
)}
<td className="py-3 px-4">
<Badge>{typeLabels[contract.type]}</Badge>
</td>
<td className="py-3 px-4">
{contract.providerName || '-'}
{contract.tariffName && (
<span className="text-gray-500"> / {contract.tariffName}</span>
)}
</td>
<td className="py-3 px-4">
<Badge variant={statusVariants[contract.status]}>
{statusLabels[contract.status]}
</Badge>
</td>
<td className="py-3 px-4">
{contract.startDate
? new Date(contract.startDate).toLocaleDateString('de-DE')
: '-'}
</td>
<td className="py-3 px-4 text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/contracts/${contract.id}`, {
state: { from: 'contracts' }
})}
>
<Eye className="w-4 h-4" />
</Button>
{hasPermission('contracts:update') && !isCustomer && (
<Link to={`/contracts/${contract.id}/edit`}>
<Button variant="ghost" size="sm">
<Edit className="w-4 h-4" />
</Button>
</Link>
)}
{hasPermission('contracts:delete') && !isCustomer && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Vertrag wirklich löschen?')) {
deleteMutation.mutate(contract.id);
}
}}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{data.pagination && data.pagination.totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-gray-500">
Seite {data.pagination.page} von {data.pagination.totalPages} (
{data.pagination.total} Einträge)
</p>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
Zurück
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setPage((p) => p + 1)}
disabled={page >= data.pagination.totalPages}
>
Weiter
</Button>
</div>
</div>
)}
</Card>
)}
</>
) : (
<Card>
<div className="text-center py-8 text-gray-500">Keine Verträge gefunden.</div>
</Card>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,245 @@
import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { customerApi } from '../../services/api';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
import type { Customer } from '../../types';
type CustomerFormData = Omit<Customer, 'id' | 'customerNumber' | 'createdAt' | 'updatedAt' | 'addresses' | 'bankCards' | 'identityDocuments' | 'meters' | 'contracts'>;
export default function CustomerForm() {
const { id } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const isEdit = !!id;
const { register, handleSubmit, reset, watch, setValue, formState: { errors } } = useForm<CustomerFormData>();
const customerType = watch('type');
const { data: customer } = useQuery({
queryKey: ['customer', id],
queryFn: () => customerApi.getById(parseInt(id!)),
enabled: isEdit,
});
useEffect(() => {
if (customer?.data) {
const data = { ...customer.data };
// Convert date strings to YYYY-MM-DD format for date inputs
if (data.birthDate) {
data.birthDate = data.birthDate.split('T')[0] as any;
}
if (data.foundingDate) {
data.foundingDate = data.foundingDate.split('T')[0] as any;
}
reset(data);
}
}, [customer, reset]);
const createMutation = useMutation({
mutationFn: customerApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customers'] });
navigate('/customers');
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<Customer>) => customerApi.update(parseInt(id!), data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customers'] });
queryClient.invalidateQueries({ queryKey: ['customer', id] });
navigate(`/customers/${id}`);
},
});
const onSubmit = (data: CustomerFormData) => {
// Only include the fields that can be updated - exclude relations and read-only fields
const submitData: any = {
type: data.type,
salutation: data.salutation || undefined,
firstName: data.firstName,
lastName: data.lastName,
companyName: data.companyName || undefined,
email: data.email || undefined,
phone: data.phone || undefined,
mobile: data.mobile || undefined,
taxNumber: data.taxNumber || undefined,
commercialRegisterNumber: data.commercialRegisterNumber || undefined,
notes: data.notes || undefined,
birthPlace: data.birthPlace || undefined,
};
// Handle birthDate - convert non-empty string to ISO string, or null to clear
if (data.birthDate && typeof data.birthDate === 'string' && data.birthDate.trim() !== '') {
submitData.birthDate = new Date(data.birthDate).toISOString();
} else {
submitData.birthDate = null;
}
// Handle foundingDate for business customers - or null to clear
if (data.foundingDate && typeof data.foundingDate === 'string' && data.foundingDate.trim() !== '') {
submitData.foundingDate = new Date(data.foundingDate).toISOString();
} else {
submitData.foundingDate = null;
}
if (isEdit) {
updateMutation.mutate(submitData);
} else {
createMutation.mutate(submitData);
}
};
const isLoading = createMutation.isPending || updateMutation.isPending;
const error = createMutation.error || updateMutation.error;
return (
<div>
<h1 className="text-2xl font-bold mb-6">
{isEdit ? 'Kunde bearbeiten' : 'Neuer Kunde'}
</h1>
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
{error instanceof Error ? error.message : 'Ein Fehler ist aufgetreten'}
</div>
)}
<form onSubmit={handleSubmit(onSubmit)}>
<Card className="mb-6" title="Stammdaten">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Select
label="Kundentyp"
{...register('type')}
options={[
{ value: 'PRIVATE', label: 'Privatkunde' },
{ value: 'BUSINESS', label: 'Geschäftskunde' },
]}
/>
<Select
label="Anrede"
{...register('salutation')}
options={[
{ value: 'Herr', label: 'Herr' },
{ value: 'Frau', label: 'Frau' },
{ value: 'Divers', label: 'Divers' },
]}
/>
<Input
label="Vorname"
{...register('firstName', { required: 'Vorname erforderlich' })}
error={errors.firstName?.message}
/>
<Input
label="Nachname"
{...register('lastName', { required: 'Nachname erforderlich' })}
error={errors.lastName?.message}
/>
{customerType === 'BUSINESS' && (
<>
<Input
label="Firmenname"
{...register('companyName')}
className="md:col-span-2"
/>
<Input
label="Gründungsdatum"
type="date"
{...register('foundingDate')}
value={watch('foundingDate') || ''}
onClear={() => setValue('foundingDate', '' as any)}
/>
</>
)}
{customerType !== 'BUSINESS' && (
<>
<Input
label="Geburtsdatum"
type="date"
{...register('birthDate')}
value={watch('birthDate') || ''}
onClear={() => setValue('birthDate', '' as any)}
/>
<Input
label="Geburtsort"
{...register('birthPlace')}
/>
</>
)}
</div>
</Card>
<Card className="mb-6" title="Kontaktdaten">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="E-Mail"
type="email"
{...register('email')}
/>
<Input
label="Telefon"
{...register('phone')}
/>
<Input
label="Mobil"
{...register('mobile')}
/>
</div>
</Card>
{customerType === 'BUSINESS' && (
<Card className="mb-6" title="Geschäftsdaten">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Steuernummer"
{...register('taxNumber')}
/>
<Input
label="Handelsregisternummer"
{...register('commercialRegisterNumber')}
placeholder="z.B. HRB 12345"
/>
</div>
{isEdit && (
<p className="mt-4 text-sm text-gray-500">
Dokumente (Gewerbeanmeldung, Handelsregisterauszug) können nach dem Speichern in der Kundendetailansicht hochgeladen werden.
</p>
)}
</Card>
)}
<Card className="mb-6" title="Notizen">
<textarea
{...register('notes')}
rows={4}
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Interne Notizen..."
/>
</Card>
<div className="flex justify-end gap-4">
<Button type="button" variant="secondary" onClick={() => navigate(-1)}>
Abbrechen
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,151 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { customerApi } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Badge from '../../components/ui/Badge';
import { Plus, Search, Eye, Edit } from 'lucide-react';
export default function CustomerList() {
const [search, setSearch] = useState('');
const [type, setType] = useState('');
const [page, setPage] = useState(1);
const { hasPermission } = useAuth();
const { data, isLoading } = useQuery({
queryKey: ['customers', search, type, page],
queryFn: () => customerApi.getAll({ search, type: type || undefined, page, limit: 20 }),
});
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Kunden</h1>
{hasPermission('customers:create') && (
<Link to="/customers/new">
<Button>
<Plus className="w-4 h-4 mr-2" />
Neuer Kunde
</Button>
</Link>
)}
</div>
<Card className="mb-6">
<div className="flex gap-2 items-center">
<Input
placeholder="Suchen..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1"
/>
<select
value={type}
onChange={(e) => setType(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg w-28 flex-shrink-0"
>
<option value="">Alle</option>
<option value="PRIVATE">Privat</option>
<option value="BUSINESS">Firma</option>
</select>
<Button variant="secondary" className="flex-shrink-0">
<Search className="w-4 h-4" />
</Button>
</div>
</Card>
<Card>
{isLoading ? (
<div className="text-center py-8 text-gray-500">Laden...</div>
) : data?.data && data.data.length > 0 ? (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4 font-medium text-gray-600">Kundennr.</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Name</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Typ</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">E-Mail</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Verträge</th>
<th className="text-right py-3 px-4 font-medium text-gray-600">Aktionen</th>
</tr>
</thead>
<tbody>
{data.data.map((customer) => (
<tr key={customer.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4 font-mono text-sm">{customer.customerNumber}</td>
<td className="py-3 px-4">
{customer.type === 'BUSINESS' && customer.companyName
? customer.companyName
: `${customer.firstName} ${customer.lastName}`}
</td>
<td className="py-3 px-4">
<Badge variant={customer.type === 'BUSINESS' ? 'info' : 'default'}>
{customer.type === 'BUSINESS' ? 'Firma' : 'Privat'}
</Badge>
</td>
<td className="py-3 px-4">{customer.email || '-'}</td>
<td className="py-3 px-4">
{(customer as any)._count?.contracts || 0}
</td>
<td className="py-3 px-4 text-right">
<div className="flex justify-end gap-2">
<Link to={`/customers/${customer.id}`}>
<Button variant="ghost" size="sm">
<Eye className="w-4 h-4" />
</Button>
</Link>
{hasPermission('customers:update') && (
<Link to={`/customers/${customer.id}/edit`}>
<Button variant="ghost" size="sm">
<Edit className="w-4 h-4" />
</Button>
</Link>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{data.pagination && data.pagination.totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-gray-500">
Seite {data.pagination.page} von {data.pagination.totalPages} ({data.pagination.total} Einträge)
</p>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
Zurück
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setPage((p) => p + 1)}
disabled={page >= data.pagination.totalPages}
>
Weiter
</Button>
</div>
</div>
)}
</>
) : (
<div className="text-center py-8 text-gray-500">
Keine Kunden gefunden.
</div>
)}
</Card>
</div>
);
}
@@ -0,0 +1,432 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { developerApi } from '../../services/api';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Badge from '../../components/ui/Badge';
import Modal from '../../components/ui/Modal';
import { Database, Table, ArrowRight, Edit, Trash2, Save, X, ChevronLeft, ChevronRight, GitBranch } from 'lucide-react';
import ERDiagram from './ERDiagram';
interface TableMeta {
name: string;
model: string;
primaryKey: string;
readonlyFields: string[];
requiredFields: string[];
relations: { field: string; targetTable: string; type: 'one' | 'many' }[];
foreignKeys: { field: string; targetTable: string }[];
}
export default function DatabaseStructure() {
const [selectedTable, setSelectedTable] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [editingRow, setEditingRow] = useState<{ id: string; data: Record<string, any> } | null>(null);
const [showERDiagram, setShowERDiagram] = useState(false);
const queryClient = useQueryClient();
const { data: schemaData, isLoading: schemaLoading, error: schemaError } = useQuery({
queryKey: ['developer-schema'],
queryFn: developerApi.getSchema,
});
// Debug logging
console.log('Schema data:', schemaData);
console.log('Schema error:', schemaError);
const { data: tableData, isLoading: tableLoading } = useQuery({
queryKey: ['developer-table', selectedTable, page],
queryFn: () => developerApi.getTableData(selectedTable!, page),
enabled: !!selectedTable,
});
const updateMutation = useMutation({
mutationFn: ({ tableName, id, data }: { tableName: string; id: string; data: Record<string, any> }) =>
developerApi.updateRow(tableName, id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['developer-table', selectedTable] });
setEditingRow(null);
},
onError: (error: any) => {
alert(error.response?.data?.error || 'Fehler beim Speichern');
},
});
const deleteMutation = useMutation({
mutationFn: ({ tableName, id }: { tableName: string; id: string }) =>
developerApi.deleteRow(tableName, id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['developer-table', selectedTable] });
},
onError: (error: any) => {
alert(error.response?.data?.error || 'Fehler beim Löschen');
},
});
const tables: TableMeta[] = schemaData?.data || [];
const currentTableMeta = tables.find((t) => t.name === selectedTable);
const getRowId = (row: any, meta: TableMeta) => {
if (meta.primaryKey.includes(',')) {
return meta.primaryKey.split(',').map((k) => row[k]).join('-');
}
return String(row[meta.primaryKey]);
};
const formatValue = (value: any): string => {
if (value === null || value === undefined) return '-';
if (typeof value === 'boolean') return value ? 'Ja' : 'Nein';
if (typeof value === 'object') {
if (value instanceof Date || (typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}/))) {
return new Date(value).toLocaleString('de-DE');
}
return JSON.stringify(value);
}
return String(value);
};
const handleSaveEdit = () => {
if (!editingRow || !selectedTable) return;
updateMutation.mutate({
tableName: selectedTable,
id: editingRow.id,
data: editingRow.data,
});
};
const handleDelete = (id: string) => {
if (!selectedTable) return;
if (!confirm('Datensatz wirklich löschen?')) return;
deleteMutation.mutate({ tableName: selectedTable, id });
};
if (schemaLoading) {
return <div className="text-center py-8">Laden...</div>;
}
const handleDiagramSelectTable = (tableName: string) => {
setSelectedTable(tableName);
setPage(1);
setShowERDiagram(false);
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Database className="w-6 h-6" />
<h1 className="text-2xl font-bold">Datenbankstruktur</h1>
</div>
<Button onClick={() => setShowERDiagram(true)}>
<GitBranch className="w-4 h-4 mr-2" />
ER-Diagramm
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Tabellen-Liste */}
<Card title="Tabellen" className="lg:col-span-1">
<div className="space-y-1 max-h-[600px] overflow-y-auto">
{tables.map((table) => (
<button
key={table.name}
onClick={() => {
setSelectedTable(table.name);
setPage(1);
}}
className={`w-full text-left px-3 py-2 rounded-lg flex items-center gap-2 transition-colors ${
selectedTable === table.name
? 'bg-blue-100 text-blue-700'
: 'hover:bg-gray-100'
}`}
>
<Table className="w-4 h-4" />
<span className="text-sm font-mono">{table.name}</span>
</button>
))}
</div>
</Card>
{/* Tabellen-Details und Daten */}
<div className="lg:col-span-3 space-y-6">
{selectedTable && currentTableMeta ? (
<>
{/* Beziehungen */}
<Card title={`${selectedTable} - Beziehungen`}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 className="text-sm font-medium text-gray-500 mb-2">Fremdschlüssel (referenziert)</h4>
{currentTableMeta.foreignKeys.length > 0 ? (
<div className="space-y-1">
{currentTableMeta.foreignKeys.map((fk) => (
<div key={fk.field} className="flex items-center gap-2 text-sm">
<span className="font-mono text-gray-600">{fk.field}</span>
<ArrowRight className="w-4 h-4 text-gray-400" />
<Badge
variant="info"
className="cursor-pointer"
onClick={() => {
setSelectedTable(fk.targetTable);
setPage(1);
}}
>
{fk.targetTable}
</Badge>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-400">Keine</p>
)}
</div>
<div>
<h4 className="text-sm font-medium text-gray-500 mb-2">Relationen (wird referenziert von)</h4>
{currentTableMeta.relations.length > 0 ? (
<div className="space-y-1">
{currentTableMeta.relations.map((rel) => (
<div key={rel.field} className="flex items-center gap-2 text-sm">
<span className="font-mono text-gray-600">{rel.field}</span>
<Badge variant={rel.type === 'many' ? 'warning' : 'default'}>
{rel.type === 'many' ? '1:n' : '1:1'}
</Badge>
<Badge
variant="info"
className="cursor-pointer"
onClick={() => {
setSelectedTable(rel.targetTable);
setPage(1);
}}
>
{rel.targetTable}
</Badge>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-400">Keine</p>
)}
</div>
</div>
<div className="mt-4 pt-4 border-t">
<div className="flex gap-4 text-sm">
<div>
<span className="text-gray-500">Primary Key:</span>{' '}
<span className="font-mono">{currentTableMeta.primaryKey}</span>
</div>
<div>
<span className="text-gray-500">Readonly:</span>{' '}
<span className="font-mono text-red-600">{currentTableMeta.readonlyFields.join(', ') || '-'}</span>
</div>
<div>
<span className="text-gray-500">Required:</span>{' '}
<span className="font-mono text-green-600">{currentTableMeta.requiredFields.join(', ') || '-'}</span>
</div>
</div>
</div>
</Card>
{/* Daten-Tabelle */}
<Card title={`${selectedTable} - Daten`}>
{tableLoading ? (
<div className="text-center py-4">Laden...</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-gray-50">
{tableData?.data && tableData.data.length > 0 &&
Object.keys(tableData.data[0]).map((key) => (
<th key={key} className="text-left py-2 px-3 font-medium text-gray-600 whitespace-nowrap">
{key}
{currentTableMeta.readonlyFields.includes(key) && (
<span className="ml-1 text-red-400 text-xs">*</span>
)}
{currentTableMeta.requiredFields.includes(key) && (
<span className="ml-1 text-green-400 text-xs">!</span>
)}
</th>
))}
<th className="text-right py-2 px-3 font-medium text-gray-600">Aktionen</th>
</tr>
</thead>
<tbody>
{tableData?.data?.map((row: any) => {
const rowId = getRowId(row, currentTableMeta);
return (
<tr key={rowId} className="border-b hover:bg-gray-50">
{Object.entries(row).map(([key, value]) => (
<td key={key} className="py-2 px-3 font-mono text-xs max-w-[200px] truncate">
{formatValue(value)}
</td>
))}
<td className="py-2 px-3 text-right whitespace-nowrap">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setEditingRow({ id: rowId, data: { ...row } })}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(rowId)}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</td>
</tr>
);
})}
{(!tableData?.data || tableData.data.length === 0) && (
<tr>
<td colSpan={100} className="py-4 text-center text-gray-500">
Keine Daten vorhanden
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
{(tableData as any)?.pagination && (tableData as any).pagination.totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-gray-500">
Seite {(tableData as any).pagination.page} von {(tableData as any).pagination.totalPages} ({(tableData as any).pagination.total} Einträge)
</p>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setPage((p) => p + 1)}
disabled={page >= (tableData as any).pagination.totalPages}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</>
)}
</Card>
</>
) : (
<Card>
<div className="text-center py-8 text-gray-500">
Wähle eine Tabelle aus der Liste aus
</div>
</Card>
)}
</div>
</div>
{/* Edit Modal */}
<Modal
isOpen={!!editingRow}
onClose={() => setEditingRow(null)}
title={`${selectedTable} bearbeiten`}
>
{editingRow && currentTableMeta && (
<div className="space-y-4 max-h-[60vh] overflow-y-auto">
{Object.entries(editingRow.data).map(([key, value]) => {
const isReadonly = currentTableMeta.readonlyFields.includes(key);
const isRequired = currentTableMeta.requiredFields.includes(key);
return (
<div key={key}>
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{isReadonly && <span className="ml-1 text-red-400">(readonly)</span>}
{isRequired && <span className="ml-1 text-green-600">*</span>}
</label>
{isReadonly ? (
<div className="px-3 py-2 bg-gray-100 rounded-lg font-mono text-sm">
{formatValue(value)}
</div>
) : typeof value === 'boolean' ? (
<select
value={String(editingRow.data[key])}
onChange={(e) =>
setEditingRow({
...editingRow,
data: { ...editingRow.data, [key]: e.target.value === 'true' },
})
}
className="w-full px-3 py-2 border rounded-lg"
>
<option value="true">Ja</option>
<option value="false">Nein</option>
</select>
) : (
<input
type={typeof value === 'number' ? 'number' : 'text'}
value={editingRow.data[key] ?? ''}
onChange={(e) =>
setEditingRow({
...editingRow,
data: {
...editingRow.data,
[key]: typeof value === 'number' ? (e.target.value ? Number(e.target.value) : null) : e.target.value || null,
},
})
}
className="w-full px-3 py-2 border rounded-lg font-mono text-sm"
disabled={isReadonly}
/>
)}
</div>
);
})}
<div className="flex justify-end gap-2 pt-4 border-t">
<Button variant="secondary" onClick={() => setEditingRow(null)}>
<X className="w-4 h-4 mr-2" />
Abbrechen
</Button>
<Button onClick={handleSaveEdit} disabled={updateMutation.isPending}>
<Save className="w-4 h-4 mr-2" />
{updateMutation.isPending ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</div>
)}
</Modal>
{/* ER Diagram Modal */}
{showERDiagram && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={() => setShowERDiagram(false)} />
<div className="relative bg-white rounded-xl shadow-2xl w-[90vw] h-[85vh] flex flex-col">
<div className="flex items-center justify-between px-6 py-4 border-b">
<div className="flex items-center gap-3">
<GitBranch className="w-5 h-5 text-blue-600" />
<h2 className="text-lg font-semibold">ER-Diagramm - Datenbankbeziehungen</h2>
</div>
<button
onClick={() => setShowERDiagram(false)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-hidden">
<ERDiagram onSelectTable={handleDiagramSelectTable} />
</div>
</div>
</div>
)}
</div>
);
}
+368
View File
@@ -0,0 +1,368 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { developerApi } from '../../services/api';
import { ZoomIn, ZoomOut, Maximize2, Move } from 'lucide-react';
import Button from '../../components/ui/Button';
interface TableMeta {
name: string;
model: string;
primaryKey: string;
readonlyFields: string[];
requiredFields: string[];
relations: { field: string; targetTable: string; type: 'one' | 'many' }[];
foreignKeys: { field: string; targetTable: string }[];
}
interface TablePosition {
x: number;
y: number;
}
interface ERDiagramProps {
onSelectTable?: (tableName: string) => void;
}
export default function ERDiagram({ onSelectTable }: ERDiagramProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [tablePositions, setTablePositions] = useState<Record<string, TablePosition>>({});
const [draggingTable, setDraggingTable] = useState<string | null>(null);
const { data: schemaData, isLoading } = useQuery({
queryKey: ['developer-schema'],
queryFn: developerApi.getSchema,
});
const tables: TableMeta[] = schemaData?.data || [];
// Calculate initial positions in a grid layout
useEffect(() => {
if (tables.length > 0 && Object.keys(tablePositions).length === 0) {
const cols = Math.ceil(Math.sqrt(tables.length));
const spacing = { x: 280, y: 200 };
const newPositions: Record<string, TablePosition> = {};
tables.forEach((table, index) => {
const col = index % cols;
const row = Math.floor(index / cols);
newPositions[table.name] = {
x: 50 + col * spacing.x,
y: 50 + row * spacing.y,
};
});
setTablePositions(newPositions);
}
}, [tables, tablePositions]);
// Pan handling
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget || (e.target as HTMLElement).tagName === 'svg') {
setIsDragging(true);
setDragStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
}
}, [pan]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (isDragging && !draggingTable) {
setPan({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y,
});
} else if (draggingTable) {
const rect = containerRef.current?.getBoundingClientRect();
if (rect) {
setTablePositions(prev => ({
...prev,
[draggingTable]: {
x: (e.clientX - rect.left - pan.x) / zoom - 100,
y: (e.clientY - rect.top - pan.y) / zoom - 20,
},
}));
}
}
}, [isDragging, draggingTable, dragStart, pan, zoom]);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
setDraggingTable(null);
}, []);
const handleZoom = (delta: number) => {
setZoom(prev => Math.min(2, Math.max(0.3, prev + delta)));
};
const handleReset = () => {
setZoom(1);
setPan({ x: 0, y: 0 });
};
// Calculate connection points between tables
const getConnections = useCallback(() => {
const connections: Array<{
from: { table: string; x: number; y: number };
to: { table: string; x: number; y: number };
type: 'one' | 'many';
label: string;
}> = [];
tables.forEach(table => {
const fromPos = tablePositions[table.name];
if (!fromPos) return;
table.foreignKeys.forEach(fk => {
const toPos = tablePositions[fk.targetTable];
if (!toPos) return;
// Find the relation type from the target table
const targetTable = tables.find(t => t.name === fk.targetTable);
const relation = targetTable?.relations.find(r => r.targetTable === table.name);
connections.push({
from: { table: table.name, x: fromPos.x + 100, y: fromPos.y + 60 },
to: { table: fk.targetTable, x: toPos.x + 100, y: toPos.y + 60 },
type: relation?.type || 'one',
label: fk.field,
});
});
});
return connections;
}, [tables, tablePositions]);
if (isLoading) {
return <div className="flex items-center justify-center h-full">Laden...</div>;
}
const connections = getConnections();
return (
<div className="relative h-full w-full bg-gray-50 overflow-hidden" ref={containerRef}>
{/* Toolbar */}
<div className="absolute top-4 right-4 z-10 flex gap-2 bg-white rounded-lg shadow-md p-2">
<Button variant="ghost" size="sm" onClick={() => handleZoom(0.1)} title="Vergrößern">
<ZoomIn className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleZoom(-0.1)} title="Verkleinern">
<ZoomOut className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={handleReset} title="Zurücksetzen">
<Maximize2 className="w-4 h-4" />
</Button>
<div className="text-xs text-gray-500 flex items-center px-2">
{Math.round(zoom * 100)}%
</div>
</div>
{/* Instructions */}
<div className="absolute top-4 left-4 z-10 bg-white rounded-lg shadow-md p-2 text-xs text-gray-500">
<Move className="w-3 h-3 inline mr-1" />
Tabellen ziehen zum Verschieben
</div>
{/* SVG Canvas */}
<svg
className="w-full h-full cursor-grab"
style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
{/* Connection lines */}
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon points="0 0, 10 3.5, 0 7" fill="#6b7280" />
</marker>
<marker
id="many-marker"
markerWidth="12"
markerHeight="12"
refX="6"
refY="6"
orient="auto"
>
<circle cx="6" cy="6" r="3" fill="#6b7280" />
</marker>
</defs>
{connections.map((conn, idx) => {
const dx = conn.to.x - conn.from.x;
const dy = conn.to.y - conn.from.y;
const midX = conn.from.x + dx / 2;
const midY = conn.from.y + dy / 2;
// Calculate control points for curved line
const ctrl1X = conn.from.x + dx * 0.25;
const ctrl1Y = conn.from.y;
const ctrl2X = conn.from.x + dx * 0.75;
const ctrl2Y = conn.to.y;
return (
<g key={idx}>
<path
d={`M ${conn.from.x} ${conn.from.y} C ${ctrl1X} ${ctrl1Y}, ${ctrl2X} ${ctrl2Y}, ${conn.to.x} ${conn.to.y}`}
fill="none"
stroke="#9ca3af"
strokeWidth="2"
markerEnd="url(#arrowhead)"
/>
{/* Relation type indicator */}
<text
x={midX}
y={midY - 8}
fontSize="10"
fill="#6b7280"
textAnchor="middle"
className="select-none"
>
{conn.type === 'many' ? '1:n' : '1:1'}
</text>
</g>
);
})}
{/* Table boxes */}
{tables.map(table => {
const pos = tablePositions[table.name];
if (!pos) return null;
const boxWidth = 200;
const headerHeight = 32;
const fieldHeight = 20;
const fields = [...new Set([table.primaryKey, ...table.foreignKeys.map(fk => fk.field)])];
const boxHeight = headerHeight + Math.min(fields.length, 5) * fieldHeight + 8;
return (
<g
key={table.name}
transform={`translate(${pos.x}, ${pos.y})`}
style={{ cursor: 'move' }}
onMouseDown={(e) => {
e.stopPropagation();
setDraggingTable(table.name);
}}
>
{/* Shadow */}
<rect
x="3"
y="3"
width={boxWidth}
height={boxHeight}
rx="6"
fill="rgba(0,0,0,0.1)"
/>
{/* Box background */}
<rect
x="0"
y="0"
width={boxWidth}
height={boxHeight}
rx="6"
fill="white"
stroke="#e5e7eb"
strokeWidth="1"
/>
{/* Header */}
<rect
x="0"
y="0"
width={boxWidth}
height={headerHeight}
rx="6"
fill="#3b82f6"
className="cursor-pointer"
onClick={() => onSelectTable?.(table.name)}
/>
<rect
x="0"
y={headerHeight - 6}
width={boxWidth}
height="6"
fill="#3b82f6"
/>
<text
x={boxWidth / 2}
y="21"
fontSize="13"
fontWeight="bold"
fill="white"
textAnchor="middle"
className="select-none pointer-events-none"
>
{table.name}
</text>
{/* Fields */}
{fields.slice(0, 5).map((field, fieldIdx) => {
const isPK = field === table.primaryKey || table.primaryKey.includes(field);
const isFK = table.foreignKeys.some(fk => fk.field === field);
return (
<g key={field} transform={`translate(8, ${headerHeight + 4 + fieldIdx * fieldHeight})`}>
<text
x="0"
y="14"
fontSize="11"
fill={isPK ? '#dc2626' : isFK ? '#2563eb' : '#374151'}
fontFamily="monospace"
className="select-none"
>
{isPK && '🔑 '}
{isFK && !isPK && '🔗 '}
{field}
</text>
</g>
);
})}
{fields.length > 5 && (
<text
x={boxWidth / 2}
y={boxHeight - 4}
fontSize="10"
fill="#9ca3af"
textAnchor="middle"
className="select-none"
>
+{fields.length - 5} mehr...
</text>
)}
</g>
);
})}
</g>
</svg>
{/* Legend */}
<div className="absolute bottom-4 left-4 bg-white rounded-lg shadow-md p-3 text-xs">
<div className="font-medium mb-2">Legende</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-red-600">🔑</span>
<span>Primary Key</span>
</div>
<div className="flex items-center gap-2">
<span className="text-blue-600">🔗</span>
<span>Foreign Key</span>
</div>
<div className="flex items-center gap-2">
<div className="w-6 h-0.5 bg-gray-400"></div>
<span>Beziehung</span>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,249 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { platformApi } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Modal from '../../components/ui/Modal';
import Badge from '../../components/ui/Badge';
import { Plus, Edit, Trash2 } from 'lucide-react';
import type { SalesPlatform } from '../../types';
export default function PlatformList() {
const [showModal, setShowModal] = useState(false);
const [editingPlatform, setEditingPlatform] = useState<SalesPlatform | null>(null);
const [showInactive, setShowInactive] = useState(false);
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['platforms', showInactive],
queryFn: () => platformApi.getAll(showInactive),
});
const deleteMutation = useMutation({
mutationFn: platformApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['platforms'] });
},
});
const handleEdit = (platform: SalesPlatform) => {
setEditingPlatform(platform);
setShowModal(true);
};
const handleClose = () => {
setShowModal(false);
setEditingPlatform(null);
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Vertriebsplattformen</h1>
{hasPermission('platforms:create') && (
<Button onClick={() => setShowModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Neue Plattform
</Button>
)}
</div>
<Card>
<div className="mb-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={showInactive}
onChange={(e) => setShowInactive(e.target.checked)}
className="rounded"
/>
Inaktive anzeigen
</label>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">Laden...</div>
) : data?.data && data.data.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4 font-medium text-gray-600">Name</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Kontakt</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Status</th>
<th className="text-right py-3 px-4 font-medium text-gray-600">Aktionen</th>
</tr>
</thead>
<tbody>
{data.data.map((platform) => (
<tr key={platform.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4 font-medium">{platform.name}</td>
<td className="py-3 px-4 text-gray-500">{platform.contactInfo || '-'}</td>
<td className="py-3 px-4">
<Badge variant={platform.isActive ? 'success' : 'danger'}>
{platform.isActive ? 'Aktiv' : 'Inaktiv'}
</Badge>
</td>
<td className="py-3 px-4 text-right">
<div className="flex justify-end gap-2">
{hasPermission('platforms:update') && (
<Button variant="ghost" size="sm" onClick={() => handleEdit(platform)}>
<Edit className="w-4 h-4" />
</Button>
)}
{hasPermission('platforms:delete') && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Plattform wirklich löschen?')) {
deleteMutation.mutate(platform.id);
}
}}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-gray-500">Keine Plattformen vorhanden.</div>
)}
</Card>
<PlatformModal
isOpen={showModal}
onClose={handleClose}
platform={editingPlatform}
/>
</div>
);
}
function PlatformModal({
isOpen,
onClose,
platform,
}: {
isOpen: boolean;
onClose: () => void;
platform: SalesPlatform | null;
}) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
name: '',
contactInfo: '',
isActive: true,
});
useState(() => {
if (platform) {
setFormData({
name: platform.name,
contactInfo: platform.contactInfo || '',
isActive: platform.isActive,
});
} else {
setFormData({ name: '', contactInfo: '', isActive: true });
}
});
// Reset form when platform changes
if (platform && formData.name !== platform.name) {
setFormData({
name: platform.name,
contactInfo: platform.contactInfo || '',
isActive: platform.isActive,
});
} else if (!platform && formData.name !== '') {
// Only reset if we're opening for new (not editing)
}
const createMutation = useMutation({
mutationFn: platformApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['platforms'] });
onClose();
setFormData({ name: '', contactInfo: '', isActive: true });
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<SalesPlatform>) =>
platformApi.update(platform!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['platforms'] });
onClose();
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (platform) {
updateMutation.mutate(formData);
} else {
createMutation.mutate(formData);
}
};
const isLoading = createMutation.isPending || updateMutation.isPending;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={platform ? 'Plattform bearbeiten' : 'Neue Plattform'}
>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Name *"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kontaktinformationen
</label>
<textarea
value={formData.contactInfo}
onChange={(e) => setFormData({ ...formData, contactInfo: e.target.value })}
rows={3}
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="E-Mail, Telefon, Ansprechpartner..."
/>
</div>
{platform && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded"
/>
Aktiv
</label>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,249 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { cancellationPeriodApi } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Modal from '../../components/ui/Modal';
import Badge from '../../components/ui/Badge';
import { Plus, Edit, Trash2, ArrowLeft } from 'lucide-react';
import { Link } from 'react-router-dom';
import type { CancellationPeriod } from '../../types';
export default function CancellationPeriodList() {
const [showModal, setShowModal] = useState(false);
const [editingPeriod, setEditingPeriod] = useState<CancellationPeriod | null>(null);
const [showInactive, setShowInactive] = useState(false);
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['cancellation-periods', showInactive],
queryFn: () => cancellationPeriodApi.getAll(showInactive),
});
const deleteMutation = useMutation({
mutationFn: cancellationPeriodApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cancellation-periods'] });
},
});
const handleEdit = (period: CancellationPeriod) => {
setEditingPeriod(period);
setShowModal(true);
};
const handleClose = () => {
setShowModal(false);
setEditingPeriod(null);
};
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Link to="/settings">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold flex-1">Kündigungsfristen</h1>
{hasPermission('platforms:create') && (
<Button onClick={() => setShowModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Neue Frist
</Button>
)}
</div>
<Card>
<div className="mb-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={showInactive}
onChange={(e) => setShowInactive(e.target.checked)}
className="rounded"
/>
Inaktive anzeigen
</label>
</div>
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg text-sm">
<strong>Code-Format:</strong> Zahl + Buchstabe (T=Tage, M=Monate, J=Jahre)
<br />
<strong>Beispiele:</strong> 14T = 14 Tage, 3M = 3 Monate, 1J = 1 Jahr
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">Laden...</div>
) : data?.data && data.data.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4 font-medium text-gray-600">Code</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Beschreibung</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Status</th>
<th className="text-right py-3 px-4 font-medium text-gray-600">Aktionen</th>
</tr>
</thead>
<tbody>
{data.data.map((period) => (
<tr key={period.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4 font-mono font-medium">{period.code}</td>
<td className="py-3 px-4">{period.description}</td>
<td className="py-3 px-4">
<Badge variant={period.isActive ? 'success' : 'danger'}>
{period.isActive ? 'Aktiv' : 'Inaktiv'}
</Badge>
</td>
<td className="py-3 px-4 text-right">
<div className="flex justify-end gap-2">
{hasPermission('platforms:update') && (
<Button variant="ghost" size="sm" onClick={() => handleEdit(period)}>
<Edit className="w-4 h-4" />
</Button>
)}
{hasPermission('platforms:delete') && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Kündigungsfrist wirklich löschen?')) {
deleteMutation.mutate(period.id);
}
}}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-gray-500">Keine Kündigungsfristen vorhanden.</div>
)}
</Card>
<CancellationPeriodModal
isOpen={showModal}
onClose={handleClose}
period={editingPeriod}
/>
</div>
);
}
function CancellationPeriodModal({
isOpen,
onClose,
period,
}: {
isOpen: boolean;
onClose: () => void;
period: CancellationPeriod | null;
}) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
code: '',
description: '',
isActive: true,
});
// Reset form when modal opens or period changes
useEffect(() => {
if (isOpen) {
if (period) {
setFormData({
code: period.code,
description: period.description,
isActive: period.isActive,
});
} else {
setFormData({ code: '', description: '', isActive: true });
}
}
}, [isOpen, period]);
const createMutation = useMutation({
mutationFn: cancellationPeriodApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cancellation-periods'] });
onClose();
setFormData({ code: '', description: '', isActive: true });
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<CancellationPeriod>) =>
cancellationPeriodApi.update(period!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cancellation-periods'] });
onClose();
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (period) {
updateMutation.mutate(formData);
} else {
createMutation.mutate(formData);
}
};
const isLoading = createMutation.isPending || updateMutation.isPending;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={period ? 'Kündigungsfrist bearbeiten' : 'Neue Kündigungsfrist'}
>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Code *"
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase() })}
required
placeholder="z.B. 14T, 3M, 1J"
/>
<Input
label="Beschreibung *"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
required
placeholder="z.B. 14 Tage, 3 Monate, 1 Jahr"
/>
{period && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded"
/>
Aktiv
</label>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,351 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { contractCategoryApi } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Modal from '../../components/ui/Modal';
import Badge from '../../components/ui/Badge';
import { Plus, Edit, Trash2, ArrowLeft, GripVertical, Zap, Flame, Wifi, Cable, Smartphone, Tv, Car, FileText } from 'lucide-react';
import { Link } from 'react-router-dom';
import type { ContractCategory } from '../../types';
// Icon-Mapping für die Anzeige
const iconMap: Record<string, React.ReactNode> = {
Zap: <Zap className="w-5 h-5" />,
Flame: <Flame className="w-5 h-5" />,
Wifi: <Wifi className="w-5 h-5" />,
Cable: <Cable className="w-5 h-5" />,
Smartphone: <Smartphone className="w-5 h-5" />,
Tv: <Tv className="w-5 h-5" />,
Car: <Car className="w-5 h-5" />,
FileText: <FileText className="w-5 h-5" />,
};
const availableIcons = [
{ value: 'Zap', label: 'Blitz (Strom)' },
{ value: 'Flame', label: 'Flamme (Gas)' },
{ value: 'Wifi', label: 'WLAN (DSL)' },
{ value: 'Cable', label: 'Kabel (Glasfaser)' },
{ value: 'Smartphone', label: 'Smartphone (Mobilfunk)' },
{ value: 'Tv', label: 'TV' },
{ value: 'Car', label: 'Auto (KFZ)' },
{ value: 'FileText', label: 'Dokument (Sonstige)' },
];
const availableColors = [
{ value: '#FFC107', label: 'Gelb' },
{ value: '#FF5722', label: 'Orange' },
{ value: '#2196F3', label: 'Blau' },
{ value: '#9C27B0', label: 'Lila' },
{ value: '#4CAF50', label: 'Grün' },
{ value: '#E91E63', label: 'Pink' },
{ value: '#607D8B', label: 'Grau' },
{ value: '#795548', label: 'Braun' },
{ value: '#00BCD4', label: 'Cyan' },
{ value: '#F44336', label: 'Rot' },
];
export default function ContractCategoryList() {
const [showModal, setShowModal] = useState(false);
const [editingCategory, setEditingCategory] = useState<ContractCategory | null>(null);
const [showInactive, setShowInactive] = useState(false);
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['contract-categories', showInactive],
queryFn: () => contractCategoryApi.getAll(showInactive),
});
const deleteMutation = useMutation({
mutationFn: contractCategoryApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract-categories'] });
},
onError: (error: Error) => {
alert(error.message);
},
});
const handleEdit = (category: ContractCategory) => {
setEditingCategory(category);
setShowModal(true);
};
const handleClose = () => {
setShowModal(false);
setEditingCategory(null);
};
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Link to="/settings">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold flex-1">Vertragstypen</h1>
{hasPermission('platforms:create') && (
<Button onClick={() => setShowModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Neuer Vertragstyp
</Button>
)}
</div>
<Card>
<div className="mb-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={showInactive}
onChange={(e) => setShowInactive(e.target.checked)}
className="rounded"
/>
Inaktive anzeigen
</label>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">Laden...</div>
) : data?.data && data.data.length > 0 ? (
<div className="space-y-2">
{data.data.map((category) => (
<div
key={category.id}
className="flex items-center p-4 border rounded-lg hover:bg-gray-50"
>
<div className="mr-3 text-gray-400">
<GripVertical className="w-5 h-5" />
</div>
<div
className="w-10 h-10 rounded-lg flex items-center justify-center mr-4"
style={{ backgroundColor: category.color || '#E5E7EB', color: '#fff' }}
>
{category.icon && iconMap[category.icon] ? iconMap[category.icon] : <FileText className="w-5 h-5" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{category.name}</span>
<Badge variant={category.isActive ? 'success' : 'danger'}>
{category.isActive ? 'Aktiv' : 'Inaktiv'}
</Badge>
<span className="text-sm text-gray-500">
({category._count?.contracts || 0} Verträge)
</span>
</div>
<div className="text-sm text-gray-500">
Code: <span className="font-mono">{category.code}</span>
</div>
</div>
<div className="flex gap-2 ml-4">
{hasPermission('platforms:update') && (
<Button variant="ghost" size="sm" onClick={() => handleEdit(category)} title="Bearbeiten">
<Edit className="w-4 h-4" />
</Button>
)}
{hasPermission('platforms:delete') && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Vertragstyp wirklich löschen?')) {
deleteMutation.mutate(category.id);
}
}}
title="Löschen"
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
)}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500">Keine Vertragstypen vorhanden.</div>
)}
</Card>
<ContractCategoryModal
isOpen={showModal}
onClose={handleClose}
category={editingCategory}
/>
</div>
);
}
function ContractCategoryModal({
isOpen,
onClose,
category,
}: {
isOpen: boolean;
onClose: () => void;
category: ContractCategory | null;
}) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
code: '',
name: '',
icon: 'FileText',
color: '#607D8B',
sortOrder: 0,
isActive: true,
});
useEffect(() => {
if (isOpen) {
if (category) {
setFormData({
code: category.code,
name: category.name,
icon: category.icon || 'FileText',
color: category.color || '#607D8B',
sortOrder: category.sortOrder,
isActive: category.isActive,
});
} else {
setFormData({
code: '',
name: '',
icon: 'FileText',
color: '#607D8B',
sortOrder: 0,
isActive: true,
});
}
}
}, [isOpen, category]);
const createMutation = useMutation({
mutationFn: contractCategoryApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract-categories'] });
onClose();
},
onError: (error: Error) => {
alert(error.message);
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<ContractCategory>) =>
contractCategoryApi.update(category!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract-categories'] });
onClose();
},
onError: (error: Error) => {
alert(error.message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (category) {
updateMutation.mutate(formData);
} else {
createMutation.mutate(formData);
}
};
const isLoading = createMutation.isPending || updateMutation.isPending;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={category ? 'Vertragstyp bearbeiten' : 'Neuer Vertragstyp'}
>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Code (technisch) *"
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '') })}
required
placeholder="z.B. ELECTRICITY, MOBILE_BUSINESS"
disabled={!!category} // Code nicht änderbar bei Bearbeitung
/>
<Input
label="Anzeigename *"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
placeholder="z.B. Strom, Mobilfunk Business"
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Icon</label>
<div className="grid grid-cols-4 gap-2">
{availableIcons.map((icon) => (
<button
key={icon.value}
type="button"
onClick={() => setFormData({ ...formData, icon: icon.value })}
className={`p-3 border rounded-lg flex flex-col items-center gap-1 text-xs ${
formData.icon === icon.value ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:bg-gray-50'
}`}
>
{iconMap[icon.value]}
<span className="truncate w-full text-center">{icon.label.split(' ')[0]}</span>
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Farbe</label>
<div className="flex flex-wrap gap-2">
{availableColors.map((color) => (
<button
key={color.value}
type="button"
onClick={() => setFormData({ ...formData, color: color.value })}
className={`w-8 h-8 rounded-full border-2 ${
formData.color === color.value ? 'border-gray-800 ring-2 ring-offset-2 ring-gray-400' : 'border-transparent'
}`}
style={{ backgroundColor: color.value }}
title={color.label}
/>
))}
</div>
</div>
<Input
label="Sortierung"
type="number"
value={formData.sortOrder}
onChange={(e) => setFormData({ ...formData, sortOrder: parseInt(e.target.value) || 0 })}
placeholder="0"
/>
{category && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded"
/>
Aktiv
</label>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,249 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { contractDurationApi } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Modal from '../../components/ui/Modal';
import Badge from '../../components/ui/Badge';
import { Plus, Edit, Trash2, ArrowLeft } from 'lucide-react';
import { Link } from 'react-router-dom';
import type { ContractDuration } from '../../types';
export default function ContractDurationList() {
const [showModal, setShowModal] = useState(false);
const [editingDuration, setEditingDuration] = useState<ContractDuration | null>(null);
const [showInactive, setShowInactive] = useState(false);
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['contract-durations', showInactive],
queryFn: () => contractDurationApi.getAll(showInactive),
});
const deleteMutation = useMutation({
mutationFn: contractDurationApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract-durations'] });
},
});
const handleEdit = (duration: ContractDuration) => {
setEditingDuration(duration);
setShowModal(true);
};
const handleClose = () => {
setShowModal(false);
setEditingDuration(null);
};
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Link to="/settings">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold flex-1">Vertragslaufzeiten</h1>
{hasPermission('platforms:create') && (
<Button onClick={() => setShowModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Neue Laufzeit
</Button>
)}
</div>
<Card>
<div className="mb-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={showInactive}
onChange={(e) => setShowInactive(e.target.checked)}
className="rounded"
/>
Inaktive anzeigen
</label>
</div>
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg text-sm">
<strong>Code-Format:</strong> Zahl + Buchstabe (T=Tage, M=Monate, J=Jahre)
<br />
<strong>Beispiele:</strong> 12M = 12 Monate, 24M = 24 Monate, 2J = 2 Jahre
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">Laden...</div>
) : data?.data && data.data.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4 font-medium text-gray-600">Code</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Beschreibung</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Status</th>
<th className="text-right py-3 px-4 font-medium text-gray-600">Aktionen</th>
</tr>
</thead>
<tbody>
{data.data.map((duration) => (
<tr key={duration.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4 font-mono font-medium">{duration.code}</td>
<td className="py-3 px-4">{duration.description}</td>
<td className="py-3 px-4">
<Badge variant={duration.isActive ? 'success' : 'danger'}>
{duration.isActive ? 'Aktiv' : 'Inaktiv'}
</Badge>
</td>
<td className="py-3 px-4 text-right">
<div className="flex justify-end gap-2">
{hasPermission('platforms:update') && (
<Button variant="ghost" size="sm" onClick={() => handleEdit(duration)}>
<Edit className="w-4 h-4" />
</Button>
)}
{hasPermission('platforms:delete') && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Laufzeit wirklich löschen?')) {
deleteMutation.mutate(duration.id);
}
}}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-gray-500">Keine Laufzeiten vorhanden.</div>
)}
</Card>
<ContractDurationModal
isOpen={showModal}
onClose={handleClose}
duration={editingDuration}
/>
</div>
);
}
function ContractDurationModal({
isOpen,
onClose,
duration,
}: {
isOpen: boolean;
onClose: () => void;
duration: ContractDuration | null;
}) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
code: '',
description: '',
isActive: true,
});
// Reset form when modal opens or duration changes
useEffect(() => {
if (isOpen) {
if (duration) {
setFormData({
code: duration.code,
description: duration.description,
isActive: duration.isActive,
});
} else {
setFormData({ code: '', description: '', isActive: true });
}
}
}, [isOpen, duration]);
const createMutation = useMutation({
mutationFn: contractDurationApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract-durations'] });
onClose();
setFormData({ code: '', description: '', isActive: true });
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<ContractDuration>) =>
contractDurationApi.update(duration!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract-durations'] });
onClose();
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (duration) {
updateMutation.mutate(formData);
} else {
createMutation.mutate(formData);
}
};
const isLoading = createMutation.isPending || updateMutation.isPending;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={duration ? 'Laufzeit bearbeiten' : 'Neue Laufzeit'}
>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Code *"
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase() })}
required
placeholder="z.B. 12M, 24M, 2J"
/>
<Input
label="Beschreibung *"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
required
placeholder="z.B. 12 Monate, 24 Monate, 2 Jahre"
/>
{duration && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded"
/>
Aktiv
</label>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,181 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { appSettingsApi } from '../../services/api';
import Card from '../../components/ui/Card';
import Input from '../../components/ui/Input';
import Button from '../../components/ui/Button';
import { ArrowLeft, Clock, AlertTriangle, AlertCircle, CheckCircle } from 'lucide-react';
export default function DeadlineSettings() {
const queryClient = useQueryClient();
const { data: settingsData, isLoading } = useQuery({
queryKey: ['app-settings'],
queryFn: () => appSettingsApi.getAll(),
});
const [criticalDays, setCriticalDays] = useState('14');
const [warningDays, setWarningDays] = useState('42');
const [okDays, setOkDays] = useState('90');
const [hasChanges, setHasChanges] = useState(false);
useEffect(() => {
if (settingsData?.data) {
setCriticalDays(settingsData.data.deadlineCriticalDays || '14');
setWarningDays(settingsData.data.deadlineWarningDays || '42');
setOkDays(settingsData.data.deadlineOkDays || '90');
setHasChanges(false);
}
}, [settingsData]);
const updateMutation = useMutation({
mutationFn: (settings: Record<string, string>) => appSettingsApi.update(settings),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['app-settings'] });
queryClient.invalidateQueries({ queryKey: ['contract-cockpit'] });
setHasChanges(false);
},
});
const handleSave = () => {
// Validierung
const critical = parseInt(criticalDays);
const warning = parseInt(warningDays);
const ok = parseInt(okDays);
if (isNaN(critical) || isNaN(warning) || isNaN(ok)) {
alert('Bitte gültige Zahlen eingeben');
return;
}
if (critical >= warning || warning >= ok) {
alert('Die Werte müssen aufsteigend sein: Kritisch < Warnung < OK');
return;
}
updateMutation.mutate({
deadlineCriticalDays: criticalDays,
deadlineWarningDays: warningDays,
deadlineOkDays: okDays,
});
};
const handleChange = (setter: (value: string) => void, value: string) => {
setter(value);
setHasChanges(true);
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-gray-500">Laden...</div>
</div>
);
}
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Link to="/settings" className="text-gray-500 hover:text-gray-700">
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="flex items-center gap-3">
<Clock className="w-6 h-6" />
<h1 className="text-2xl font-bold">Fristenschwellen</h1>
</div>
</div>
<Card title="Farbkodierung für Fristen">
<p className="text-gray-600 mb-6">
Definiere, ab wann Vertragsfristen als kritisch (rot), Warnung (gelb) oder OK (grün)
angezeigt werden sollen. Die Werte geben die Anzahl der Tage bis zur Frist an.
</p>
<div className="space-y-6">
{/* Kritisch (Rot) */}
<div className="flex items-center gap-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<AlertCircle className="w-8 h-8 text-red-500 flex-shrink-0" />
<div className="flex-1">
<label className="block font-medium text-red-800 mb-1">
Kritisch (Rot)
</label>
<p className="text-sm text-red-600 mb-2">
Fristen mit weniger als X Tagen werden rot markiert
</p>
<div className="flex items-center gap-2">
<Input
type="number"
min="1"
value={criticalDays}
onChange={(e) => handleChange(setCriticalDays, e.target.value)}
className="w-24"
/>
<span className="text-red-700">Tage</span>
</div>
</div>
</div>
{/* Warnung (Gelb) */}
<div className="flex items-center gap-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<AlertTriangle className="w-8 h-8 text-yellow-500 flex-shrink-0" />
<div className="flex-1">
<label className="block font-medium text-yellow-800 mb-1">
Warnung (Gelb)
</label>
<p className="text-sm text-yellow-600 mb-2">
Fristen mit weniger als X Tagen werden gelb markiert
</p>
<div className="flex items-center gap-2">
<Input
type="number"
min="1"
value={warningDays}
onChange={(e) => handleChange(setWarningDays, e.target.value)}
className="w-24"
/>
<span className="text-yellow-700">Tage</span>
</div>
</div>
</div>
{/* OK (Grün) */}
<div className="flex items-center gap-4 p-4 bg-green-50 border border-green-200 rounded-lg">
<CheckCircle className="w-8 h-8 text-green-500 flex-shrink-0" />
<div className="flex-1">
<label className="block font-medium text-green-800 mb-1">
OK (Grün)
</label>
<p className="text-sm text-green-600 mb-2">
Fristen mit weniger als X Tagen werden grün markiert (darüber nicht angezeigt)
</p>
<div className="flex items-center gap-2">
<Input
type="number"
min="1"
value={okDays}
onChange={(e) => handleChange(setOkDays, e.target.value)}
className="w-24"
/>
<span className="text-green-700">Tage</span>
</div>
</div>
</div>
</div>
<div className="mt-6 pt-4 border-t flex justify-between items-center">
<p className="text-sm text-gray-500">
Beispiel: Bei 14/42/90 Tagen wird eine Frist die in 10 Tagen abläuft rot,
eine in 30 Tagen gelb, und eine in 60 Tagen grün markiert.
</p>
<Button
onClick={handleSave}
disabled={!hasChanges || updateMutation.isPending}
>
{updateMutation.isPending ? 'Speichere...' : 'Speichern'}
</Button>
</div>
</Card>
</div>
);
}
@@ -0,0 +1,539 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { emailProviderApi, EmailProviderConfig } from '../../services/api';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
import { ArrowLeft, Plus, Edit, Trash2, Check, X, Wifi, WifiOff, Eye, EyeOff } from 'lucide-react';
const PROVIDER_TYPES = [
{ value: 'PLESK', label: 'Plesk' },
{ value: 'CPANEL', label: 'cPanel' },
{ value: 'DIRECTADMIN', label: 'DirectAdmin' },
];
interface ProviderFormData {
name: string;
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
apiUrl: string;
apiKey: string;
username: string;
password: string;
domain: string;
defaultForwardEmail: string;
isActive: boolean;
isDefault: boolean;
}
const emptyForm: ProviderFormData = {
name: '',
type: 'PLESK',
apiUrl: '',
apiKey: '',
username: '',
password: '',
domain: 'stressfrei-wechseln.de',
defaultForwardEmail: '',
isActive: true,
isDefault: false,
};
interface TestResult {
success: boolean;
message?: string;
error?: string;
}
export default function EmailProviders() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formData, setFormData] = useState<ProviderFormData>(emptyForm);
const [showPassword, setShowPassword] = useState(false);
const [modalTestResult, setModalTestResult] = useState<TestResult | null>(null);
const [isTestingInModal, setIsTestingInModal] = useState(false);
// Test-Status pro Provider in der Liste
const [providerTestResults, setProviderTestResults] = useState<Record<number, TestResult | null>>({});
const [testingProviderId, setTestingProviderId] = useState<number | null>(null);
const { data: configsData, isLoading } = useQuery({
queryKey: ['email-provider-configs'],
queryFn: () => emailProviderApi.getConfigs(),
});
const createMutation = useMutation({
mutationFn: (data: Partial<EmailProviderConfig> & { password?: string }) =>
emailProviderApi.createConfig(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
closeModal();
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<EmailProviderConfig> & { password?: string } }) =>
emailProviderApi.updateConfig(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
closeModal();
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => emailProviderApi.deleteConfig(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
},
});
const configs = configsData?.data || [];
const openCreateModal = () => {
setFormData(emptyForm);
setEditingId(null);
setShowPassword(false);
setModalTestResult(null);
setIsModalOpen(true);
};
const openEditModal = (config: EmailProviderConfig) => {
setFormData({
name: config.name,
type: config.type,
apiUrl: config.apiUrl,
apiKey: config.apiKey || '',
username: config.username || '',
password: '', // Passwort wird nicht geladen
domain: config.domain,
defaultForwardEmail: config.defaultForwardEmail || '',
isActive: config.isActive,
isDefault: config.isDefault,
});
setEditingId(config.id);
setShowPassword(false);
setModalTestResult(null);
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
setEditingId(null);
setFormData(emptyForm);
setShowPassword(false);
setModalTestResult(null);
};
// Test für einen gespeicherten Provider in der Liste
const handleTestProvider = async (config: EmailProviderConfig) => {
setTestingProviderId(config.id);
setProviderTestResults((prev) => ({ ...prev, [config.id]: null }));
try {
// Provider per ID testen (Backend holt das gespeicherte Passwort)
const result = await emailProviderApi.testConnection({ id: config.id });
const testResult: TestResult = {
success: result.data?.success || false,
message: result.data?.message,
error: result.data?.error,
};
setProviderTestResults((prev) => ({ ...prev, [config.id]: testResult }));
} catch (error) {
setProviderTestResults((prev) => ({
...prev,
[config.id]: {
success: false,
error: error instanceof Error ? error.message : 'Unbekannter Fehler beim Testen',
},
}));
} finally {
setTestingProviderId(null);
}
};
// Test im Modal mit aktuellen Formulardaten
const handleTestInModal = async () => {
// Nur URL und Domain sind Pflicht - Auth-Fehler kommen vom Backend
if (!formData.apiUrl || !formData.domain) {
setModalTestResult({ success: false, error: 'Bitte geben Sie API-URL und Domain ein.' });
return;
}
setIsTestingInModal(true);
setModalTestResult(null);
try {
const result = await emailProviderApi.testConnection({
testData: {
type: formData.type,
apiUrl: formData.apiUrl,
apiKey: formData.apiKey || undefined,
username: formData.username || undefined,
password: formData.password || undefined,
domain: formData.domain,
}
});
setModalTestResult({
success: result.data?.success || false,
message: result.data?.message,
error: result.data?.error,
});
} catch (error) {
setModalTestResult({
success: false,
error: error instanceof Error ? error.message : 'Unbekannter Fehler beim Verbindungstest'
});
} finally {
setIsTestingInModal(false);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data: Partial<EmailProviderConfig> & { password?: string } = {
name: formData.name,
type: formData.type,
apiUrl: formData.apiUrl,
apiKey: formData.apiKey, // Leerer String wird im Backend zu null
username: formData.username,
domain: formData.domain,
defaultForwardEmail: formData.defaultForwardEmail,
isActive: formData.isActive,
isDefault: formData.isDefault,
};
// Passwort nur senden wenn eingegeben
if (formData.password) {
data.password = formData.password;
}
if (editingId) {
updateMutation.mutate({ id: editingId, data });
} else {
createMutation.mutate(data);
}
};
const handleDelete = (id: number, name: string) => {
if (confirm(`Möchten Sie den Provider "${name}" wirklich löschen?`)) {
deleteMutation.mutate(id);
}
};
// Fehlermeldung benutzerfreundlich formatieren
const formatErrorMessage = (result: TestResult): string => {
if (result.error) return result.error;
if (result.message) return result.message;
return 'Verbindung fehlgeschlagen';
};
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Button variant="ghost" onClick={() => navigate('/settings')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Zurück
</Button>
<h1 className="text-2xl font-bold">Email-Provisionierung</h1>
</div>
<Card className="mb-6">
<p className="text-gray-600 mb-4">
Hier konfigurieren Sie die automatische Erstellung von Stressfrei-Wechseln E-Mail-Adressen.
Wenn beim Anlegen einer Stressfrei-Adresse die Option "Bei Provider anlegen" aktiviert ist,
wird die E-Mail-Weiterleitung automatisch erstellt.
</p>
<Button onClick={openCreateModal}>
<Plus className="w-4 h-4 mr-2" />
Provider hinzufügen
</Button>
</Card>
{isLoading ? (
<div className="text-center py-8">Laden...</div>
) : configs.length === 0 ? (
<Card>
<div className="text-center py-8 text-gray-500">
Noch keine Email-Provider konfiguriert.
</div>
</Card>
) : (
<div className="space-y-4">
{configs.map((config) => {
const testResult = providerTestResults[config.id];
const isTesting = testingProviderId === config.id;
return (
<Card key={config.id}>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-lg">{config.name}</h3>
<span className="px-2 py-1 text-xs rounded bg-blue-100 text-blue-800">
{config.type}
</span>
{config.isDefault && (
<span className="px-2 py-1 text-xs rounded bg-green-100 text-green-800">
Standard
</span>
)}
{!config.isActive && (
<span className="px-2 py-1 text-xs rounded bg-gray-100 text-gray-600">
Inaktiv
</span>
)}
</div>
<dl className="mt-3 grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<dt className="text-gray-500">API-URL</dt>
<dd className="font-mono text-xs truncate">{config.apiUrl}</dd>
</div>
<div>
<dt className="text-gray-500">Domain</dt>
<dd>{config.domain}</dd>
</div>
<div>
<dt className="text-gray-500">Benutzer</dt>
<dd>{config.username || '-'}</dd>
</div>
<div>
<dt className="text-gray-500">Standard-Weiterleitung</dt>
<dd className="truncate">{config.defaultForwardEmail || '-'}</dd>
</div>
</dl>
{/* Test-Ergebnis für diesen Provider */}
{testResult && (
<div className={`mt-3 p-3 rounded-lg text-sm ${testResult.success ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'}`}>
{testResult.success ? (
<div className="flex items-center gap-2">
<Check className="w-4 h-4 flex-shrink-0" />
<span>Verbindung erfolgreich!</span>
</div>
) : (
<div className="flex items-start gap-2">
<WifiOff className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>{formatErrorMessage(testResult)}</span>
</div>
)}
</div>
)}
</div>
<div className="flex gap-2 ml-4">
<Button
variant="ghost"
size="sm"
onClick={() => handleTestProvider(config)}
disabled={isTesting}
title="Verbindung testen"
>
{isTesting ? (
<span className="w-4 h-4 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
) : (
<Wifi className="w-4 h-4 text-blue-500" />
)}
</Button>
<Button variant="ghost" size="sm" onClick={() => openEditModal(config)}>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(config.id, config.name)}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</div>
</Card>
);
})}
</div>
)}
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">
{editingId ? 'Provider bearbeiten' : 'Neuer Provider'}
</h2>
<button onClick={closeModal} className="text-gray-400 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
</div>
{/* Mutation Fehler anzeigen */}
{(createMutation.error || updateMutation.error) && (
<div className="mb-4 p-3 rounded-lg bg-red-50 text-red-800 text-sm">
<div className="flex items-start gap-2">
<X className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>
{createMutation.error instanceof Error
? createMutation.error.message
: updateMutation.error instanceof Error
? updateMutation.error.message
: 'Fehler beim Speichern'}
</span>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Name *"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="z.B. Plesk Hauptserver"
required
/>
<Select
label="Provider-Typ *"
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as 'PLESK' | 'CPANEL' | 'DIRECTADMIN' })}
options={PROVIDER_TYPES}
/>
<Input
label="API-URL *"
value={formData.apiUrl}
onChange={(e) => setFormData({ ...formData, apiUrl: e.target.value })}
placeholder="https://server.de:8443"
required
/>
<Input
label="API-Key"
value={formData.apiKey}
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
placeholder="Optional - alternativ zu Benutzername/Passwort"
/>
<div className="grid grid-cols-2 gap-4">
<Input
label="Benutzername"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
placeholder="admin"
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{editingId ? 'Neues Passwort (leer = beibehalten)' : 'Passwort'}
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
</div>
<Input
label="Domain *"
value={formData.domain}
onChange={(e) => setFormData({ ...formData, domain: e.target.value })}
placeholder="stressfrei-wechseln.de"
required
/>
<Input
label="Standard-Weiterleitungsadresse"
value={formData.defaultForwardEmail}
onChange={(e) => setFormData({ ...formData, defaultForwardEmail: e.target.value })}
placeholder="info@meinefirma.de"
type="email"
/>
<p className="text-xs text-gray-500 -mt-2">
Diese E-Mail-Adresse wird zusätzlich zur Kunden-E-Mail als Weiterleitungsziel hinzugefügt.
</p>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded border-gray-300"
/>
<span className="text-sm">Aktiv</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.isDefault}
onChange={(e) => setFormData({ ...formData, isDefault: e.target.checked })}
className="rounded border-gray-300"
/>
<span className="text-sm">Als Standard verwenden</span>
</label>
</div>
{/* Verbindungstest im Modal */}
<div className="pt-4 border-t">
<Button
type="button"
variant="secondary"
onClick={handleTestInModal}
disabled={isTestingInModal}
className="w-full"
>
{isTestingInModal ? (
'Teste Verbindung...'
) : (
<>
<Wifi className="w-4 h-4 mr-2" />
Verbindung testen
</>
)}
</Button>
{modalTestResult && (
<div className={`mt-2 p-3 rounded-lg text-sm ${modalTestResult.success ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'}`}>
{modalTestResult.success ? (
<div className="flex items-center gap-2">
<Check className="w-4 h-4 flex-shrink-0" />
<span>Verbindung erfolgreich!</span>
</div>
) : (
<div className="flex items-start gap-2">
<WifiOff className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>{formatErrorMessage(modalTestResult)}</span>
</div>
)}
</div>
)}
</div>
<div className="flex justify-end gap-3 pt-4 border-t">
<Button type="button" variant="secondary" onClick={closeModal}>
Abbrechen
</Button>
<Button
type="submit"
disabled={createMutation.isPending || updateMutation.isPending}
>
{(createMutation.isPending || updateMutation.isPending) ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,93 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { appSettingsApi } from '../../services/api';
import Card from '../../components/ui/Card';
import { ArrowLeft, Globe, MessageSquare } from 'lucide-react';
export default function PortalSettings() {
const queryClient = useQueryClient();
const { data: settingsData, isLoading } = useQuery({
queryKey: ['app-settings'],
queryFn: () => appSettingsApi.getAll(),
});
const [customerSupportEnabled, setCustomerSupportEnabled] = useState(false);
useEffect(() => {
if (settingsData?.data) {
setCustomerSupportEnabled(settingsData.data.customerSupportTicketsEnabled === 'true');
}
}, [settingsData]);
const updateMutation = useMutation({
mutationFn: (settings: Record<string, string>) => appSettingsApi.update(settings),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['app-settings'] });
queryClient.invalidateQueries({ queryKey: ['app-settings-public'] });
},
});
const handleSupportToggle = (enabled: boolean) => {
setCustomerSupportEnabled(enabled);
updateMutation.mutate({ customerSupportTicketsEnabled: enabled ? 'true' : 'false' });
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-gray-500">Laden...</div>
</div>
);
}
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Link to="/settings" className="text-gray-500 hover:text-gray-700">
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="flex items-center gap-3">
<Globe className="w-6 h-6" />
<h1 className="text-2xl font-bold">Kundenportal</h1>
</div>
</div>
<Card title="Support-Anfragen">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<MessageSquare className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Kunden können Support-Anfragen erstellen</p>
<p className="text-sm text-gray-500">
Wenn aktiviert, können Kunden im Portal Support-Anfragen zu ihren Verträgen erstellen.
Diese erscheinen als Aufgaben in der Vertragsdetailansicht.
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={customerSupportEnabled}
onChange={(e) => handleSupportToggle(e.target.checked)}
disabled={updateMutation.isPending}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
{customerSupportEnabled && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800">
<strong>Hinweis:</strong> Kunden sehen diese Anfragen als "Support-Anfragen" in ihrem Portal.
Sie können die Anfrage mit einem Titel und einer Beschreibung erstellen.
Ihre Mitarbeiter können dann mit Antworten (Unteraufgaben) reagieren.
</p>
</div>
)}
</Card>
</div>
);
}
@@ -0,0 +1,521 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { providerApi, tariffApi } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Modal from '../../components/ui/Modal';
import Badge from '../../components/ui/Badge';
import { Plus, Edit, Trash2, ArrowLeft, ChevronDown, ChevronRight, ExternalLink } from 'lucide-react';
import { Link } from 'react-router-dom';
import type { Provider, Tariff } from '../../types';
export default function ProviderList() {
const [showModal, setShowModal] = useState(false);
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
const [showInactive, setShowInactive] = useState(false);
const [expandedProviders, setExpandedProviders] = useState<Set<number>>(new Set());
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['providers', showInactive],
queryFn: () => providerApi.getAll(showInactive),
});
const deleteMutation = useMutation({
mutationFn: providerApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['providers'] });
},
onError: (error: Error) => {
alert(error.message);
},
});
const toggleExpanded = (providerId: number) => {
setExpandedProviders(prev => {
const next = new Set(prev);
if (next.has(providerId)) {
next.delete(providerId);
} else {
next.add(providerId);
}
return next;
});
};
const handleEdit = (provider: Provider) => {
setEditingProvider(provider);
setShowModal(true);
};
const handleClose = () => {
setShowModal(false);
setEditingProvider(null);
};
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Link to="/settings">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold flex-1">Anbieter & Tarife</h1>
{hasPermission('providers:create') && (
<Button onClick={() => setShowModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Neuer Anbieter
</Button>
)}
</div>
<Card>
<div className="mb-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={showInactive}
onChange={(e) => setShowInactive(e.target.checked)}
className="rounded"
/>
Inaktive anzeigen
</label>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">Laden...</div>
) : data?.data && data.data.length > 0 ? (
<div className="space-y-2">
{data.data.map((provider) => (
<ProviderRow
key={provider.id}
provider={provider}
isExpanded={expandedProviders.has(provider.id)}
onToggle={() => toggleExpanded(provider.id)}
onEdit={() => handleEdit(provider)}
onDelete={() => {
if (confirm('Anbieter wirklich löschen?')) {
deleteMutation.mutate(provider.id);
}
}}
hasPermission={hasPermission}
showInactive={showInactive}
/>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500">Keine Anbieter vorhanden.</div>
)}
</Card>
<ProviderModal
isOpen={showModal}
onClose={handleClose}
provider={editingProvider}
/>
</div>
);
}
function ProviderRow({
provider,
isExpanded,
onToggle,
onEdit,
onDelete,
hasPermission,
showInactive,
}: {
provider: Provider;
isExpanded: boolean;
onToggle: () => void;
onEdit: () => void;
onDelete: () => void;
hasPermission: (permission: string) => boolean;
showInactive: boolean;
}) {
const [showTariffModal, setShowTariffModal] = useState(false);
const [editingTariff, setEditingTariff] = useState<Tariff | null>(null);
const queryClient = useQueryClient();
const deleteTariffMutation = useMutation({
mutationFn: tariffApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['providers'] });
},
onError: (error: Error) => {
alert(error.message);
},
});
const tariffs = provider.tariffs?.filter(t => showInactive || t.isActive) || [];
return (
<div className="border rounded-lg">
<div className="flex items-center p-4 hover:bg-gray-50">
<button onClick={onToggle} className="mr-3 p-1 hover:bg-gray-200 rounded">
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-gray-400" />
) : (
<ChevronRight className="w-5 h-5 text-gray-400" />
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{provider.name}</span>
<Badge variant={provider.isActive ? 'success' : 'danger'}>
{provider.isActive ? 'Aktiv' : 'Inaktiv'}
</Badge>
<span className="text-sm text-gray-500">
({tariffs.length} Tarife, {provider._count?.contracts || 0} Verträge)
</span>
</div>
{provider.portalUrl && (
<a
href={provider.portalUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline flex items-center gap-1 mt-1"
>
<ExternalLink className="w-3 h-3" />
{provider.portalUrl}
</a>
)}
</div>
<div className="flex gap-2 ml-4">
{hasPermission('providers:update') && (
<Button variant="ghost" size="sm" onClick={onEdit} title="Bearbeiten">
<Edit className="w-4 h-4" />
</Button>
)}
{hasPermission('providers:delete') && (
<Button variant="ghost" size="sm" onClick={onDelete} title="Löschen">
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
)}
</div>
</div>
{isExpanded && (
<div className="border-t bg-gray-50 p-4">
<div className="flex justify-between items-center mb-3">
<h4 className="font-medium text-gray-700">Tarife</h4>
{hasPermission('providers:create') && (
<Button size="sm" onClick={() => setShowTariffModal(true)}>
<Plus className="w-4 h-4 mr-1" />
Tarif hinzufügen
</Button>
)}
</div>
{tariffs.length > 0 ? (
<div className="space-y-2">
{tariffs.map((tariff) => (
<div key={tariff.id} className="flex items-center justify-between bg-white p-3 rounded border">
<div className="flex items-center gap-2">
<span>{tariff.name}</span>
<Badge variant={tariff.isActive ? 'success' : 'danger'} className="text-xs">
{tariff.isActive ? 'Aktiv' : 'Inaktiv'}
</Badge>
{tariff._count?.contracts !== undefined && (
<span className="text-xs text-gray-500">
({tariff._count.contracts} Verträge)
</span>
)}
</div>
<div className="flex gap-1">
{hasPermission('providers:update') && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingTariff(tariff);
setShowTariffModal(true);
}}
title="Bearbeiten"
>
<Edit className="w-3 h-3" />
</Button>
)}
{hasPermission('providers:delete') && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Tarif wirklich löschen?')) {
deleteTariffMutation.mutate(tariff.id);
}
}}
title="Löschen"
>
<Trash2 className="w-3 h-3 text-red-500" />
</Button>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500">Keine Tarife vorhanden.</p>
)}
</div>
)}
<TariffModal
isOpen={showTariffModal}
onClose={() => {
setShowTariffModal(false);
setEditingTariff(null);
}}
providerId={provider.id}
tariff={editingTariff}
/>
</div>
);
}
function ProviderModal({
isOpen,
onClose,
provider,
}: {
isOpen: boolean;
onClose: () => void;
provider: Provider | null;
}) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
name: '',
portalUrl: '',
usernameFieldName: '',
passwordFieldName: '',
isActive: true,
});
useEffect(() => {
if (isOpen) {
if (provider) {
setFormData({
name: provider.name,
portalUrl: provider.portalUrl || '',
usernameFieldName: provider.usernameFieldName || '',
passwordFieldName: provider.passwordFieldName || '',
isActive: provider.isActive,
});
} else {
setFormData({
name: '',
portalUrl: '',
usernameFieldName: '',
passwordFieldName: '',
isActive: true,
});
}
}
}, [isOpen, provider]);
const createMutation = useMutation({
mutationFn: providerApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['providers'] });
onClose();
},
onError: (error: Error) => {
alert(error.message);
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<Provider>) =>
providerApi.update(provider!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['providers'] });
onClose();
},
onError: (error: Error) => {
alert(error.message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (provider) {
updateMutation.mutate(formData);
} else {
createMutation.mutate(formData);
}
};
const isLoading = createMutation.isPending || updateMutation.isPending;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={provider ? 'Anbieter bearbeiten' : 'Neuer Anbieter'}
>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Anbietername *"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
placeholder="z.B. Vodafone, E.ON, Allianz"
/>
<Input
label="Portal-URL (Login-Seite)"
value={formData.portalUrl}
onChange={(e) => setFormData({ ...formData, portalUrl: e.target.value })}
placeholder="https://kundenportal.anbieter.de/login"
/>
<div className="p-3 bg-gray-50 rounded-lg space-y-3">
<p className="text-sm text-gray-600">
<strong>Auto-Login Felder</strong> (optional)<br />
Feldnamen für URL-Parameter beim Auto-Login:
</p>
<Input
label="Benutzername-Feldname"
value={formData.usernameFieldName}
onChange={(e) => setFormData({ ...formData, usernameFieldName: e.target.value })}
placeholder="z.B. username, email, login"
/>
<Input
label="Passwort-Feldname"
value={formData.passwordFieldName}
onChange={(e) => setFormData({ ...formData, passwordFieldName: e.target.value })}
placeholder="z.B. password, pwd, kennwort"
/>
</div>
{provider && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded"
/>
Aktiv
</label>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</Modal>
);
}
function TariffModal({
isOpen,
onClose,
providerId,
tariff,
}: {
isOpen: boolean;
onClose: () => void;
providerId: number;
tariff: Tariff | null;
}) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
name: '',
isActive: true,
});
useEffect(() => {
if (isOpen) {
if (tariff) {
setFormData({
name: tariff.name,
isActive: tariff.isActive,
});
} else {
setFormData({ name: '', isActive: true });
}
}
}, [isOpen, tariff]);
const createMutation = useMutation({
mutationFn: (data: { name: string }) => providerApi.createTariff(providerId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['providers'] });
onClose();
},
onError: (error: Error) => {
alert(error.message);
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<Tariff>) => tariffApi.update(tariff!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['providers'] });
onClose();
},
onError: (error: Error) => {
alert(error.message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (tariff) {
updateMutation.mutate(formData);
} else {
createMutation.mutate(formData);
}
};
const isLoading = createMutation.isPending || updateMutation.isPending;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={tariff ? 'Tarif bearbeiten' : 'Neuer Tarif'}
>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Tarifname *"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
placeholder="z.B. Comfort Plus, Basic 100"
/>
{tariff && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded"
/>
Aktiv
</label>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,59 @@
import { Link } from 'react-router-dom';
import { useAppSettings } from '../../context/AppSettingsContext';
import Card from '../../components/ui/Card';
import Select from '../../components/ui/Select';
import { ArrowLeft, Eye } from 'lucide-react';
const scrollThresholdOptions = [
{ value: '0.1', label: '10%' },
{ value: '0.2', label: '20%' },
{ value: '0.3', label: '30%' },
{ value: '0.4', label: '40%' },
{ value: '0.5', label: '50%' },
{ value: '0.6', label: '60%' },
{ value: '0.7', label: '70% (Standard)' },
{ value: '0.8', label: '80%' },
{ value: '0.9', label: '90%' },
{ value: '999', label: 'Deaktiviert' },
];
export default function ViewSettings() {
const { settings, updateSettings } = useAppSettings();
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Link
to="/settings"
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="flex items-center gap-3">
<Eye className="w-6 h-6" />
<h1 className="text-2xl font-bold">Ansicht</h1>
</div>
</div>
<Card title="Scroll-Verhalten">
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Nach-oben-Button</p>
<p className="text-sm text-gray-500">
Ab welcher Scroll-Position der Button unten rechts erscheinen soll
</p>
</div>
<div className="w-48">
<Select
options={scrollThresholdOptions}
value={settings.scrollToTopThreshold.toString()}
onChange={(e) => updateSettings({ scrollToTopThreshold: parseFloat(e.target.value) })}
/>
</div>
</div>
</div>
</Card>
</div>
);
}
+888
View File
@@ -0,0 +1,888 @@
import { useState, useMemo } from 'react';
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router-dom';
import { contractTaskApi, customerApi, contractApi, appSettingsApi } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Select from '../../components/ui/Select';
import Badge from '../../components/ui/Badge';
import Modal from '../../components/ui/Modal';
import Input from '../../components/ui/Input';
import {
Eye,
CheckCircle,
Circle,
Clock,
User,
Users,
Plus,
ChevronDown,
ChevronRight,
FileText,
Send,
} from 'lucide-react';
import type { ContractTask, ContractTaskStatus, Contract, Customer, ContractTaskSubtask } from '../../types';
const statusLabels: Record<ContractTaskStatus, string> = {
OPEN: 'Offen',
COMPLETED: 'Erledigt',
};
const statusVariants: Record<ContractTaskStatus, 'warning' | 'success'> = {
OPEN: 'warning',
COMPLETED: 'success',
};
export default function TaskList() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { isCustomerPortal, user, hasPermission } = useAuth();
const [statusFilter, setStatusFilter] = useState<string>('OPEN');
const [expandedTasks, setExpandedTasks] = useState<Set<number>>(new Set());
const [showCreateModal, setShowCreateModal] = useState(false);
const [replyInputs, setReplyInputs] = useState<Record<number, string>>({});
// Labels abhängig von Benutzertyp
const pageTitle = isCustomerPortal ? 'Support-Anfragen' : 'Aufgaben';
const taskLabel = isCustomerPortal ? 'Anfrage' : 'Aufgabe';
// Lade öffentliche Einstellungen (für Kundenportal - Support-Tickets aktiviert?)
const { data: publicSettings, isLoading: isLoadingSettings } = useQuery({
queryKey: ['app-settings-public'],
queryFn: () => appSettingsApi.getPublic(),
enabled: isCustomerPortal,
staleTime: 0, // Immer neu laden, damit Einstellungsänderungen sofort wirken
});
// Wichtig: Nur true wenn explizit aktiviert UND geladen
const supportTicketsEnabled = !isLoadingSettings && publicSettings?.data?.customerSupportTicketsEnabled === 'true';
const { data: tasksData, isLoading } = useQuery({
queryKey: ['all-tasks', statusFilter],
queryFn: () => contractTaskApi.getAll({
status: statusFilter as ContractTaskStatus || undefined
}),
staleTime: 0, // Immer neu laden, damit Änderungen sofort sichtbar sind
});
// Mutations für Subtasks
const completeSubtaskMutation = useMutation({
mutationFn: (subtaskId: number) => contractTaskApi.completeSubtask(subtaskId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['all-tasks'] });
queryClient.invalidateQueries({ queryKey: ['task-stats'] });
},
});
const reopenSubtaskMutation = useMutation({
mutationFn: (subtaskId: number) => contractTaskApi.reopenSubtask(subtaskId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['all-tasks'] });
queryClient.invalidateQueries({ queryKey: ['task-stats'] });
},
});
const createSubtaskMutation = useMutation({
mutationFn: ({ taskId, title }: { taskId: number; title: string }) =>
isCustomerPortal
? contractTaskApi.createReply(taskId, title)
: contractTaskApi.createSubtask(taskId, title),
onSuccess: (_, { taskId }) => {
queryClient.invalidateQueries({ queryKey: ['all-tasks'] });
setReplyInputs(prev => ({ ...prev, [taskId]: '' }));
},
});
// Gruppiere Tasks nach Vertrag für Kundenportal
const groupedTasks = useMemo(() => {
if (!tasksData?.data) return { ownTasks: [], representedTasks: [], allTasks: [] };
const tasks = tasksData.data;
if (!isCustomerPortal) {
// Für Mitarbeiter: Alle Tasks in einer Liste
return { allTasks: tasks, ownTasks: [], representedTasks: [] };
}
// Für Kundenportal: Nach eigenen vs. freigegebenen Kunden gruppieren
const ownTasks: ContractTask[] = [];
const representedTasks: ContractTask[] = [];
for (const task of tasks) {
if (task.contract?.customerId === user?.customerId) {
ownTasks.push(task);
} else {
representedTasks.push(task);
}
}
return { ownTasks, representedTasks, allTasks: [] };
}, [tasksData?.data, isCustomerPortal, user?.customerId]);
const toggleExpanded = (taskId: number) => {
setExpandedTasks(prev => {
const next = new Set(prev);
if (next.has(taskId)) {
next.delete(taskId);
} else {
next.add(taskId);
}
return next;
});
};
const handleSubtaskToggle = (subtask: ContractTaskSubtask) => {
// Verhindere Doppelklicks während Mutation läuft
if (completeSubtaskMutation.isPending || reopenSubtaskMutation.isPending) return;
if (subtask.status === 'COMPLETED') {
reopenSubtaskMutation.mutate(subtask.id);
} else {
completeSubtaskMutation.mutate(subtask.id);
}
};
const handleAddReply = (taskId: number) => {
const title = replyInputs[taskId]?.trim();
if (!title) return;
createSubtaskMutation.mutate({ taskId, title });
};
// Prüfe ob Benutzer Subtasks bearbeiten kann
const canEditSubtasks = !isCustomerPortal && hasPermission('contracts:update');
const renderTask = (task: ContractTask, showCustomer = false) => {
const isExpanded = expandedTasks.has(task.id);
const hasSubtasks = task.subtasks && task.subtasks.length > 0;
const completedSubtasks = task.subtasks?.filter(s => s.status === 'COMPLETED').length || 0;
const totalSubtasks = task.subtasks?.length || 0;
const isTaskCompleted = task.status === 'COMPLETED';
const contractDisplay = task.contract
? `${task.contract.contractNumber} - ${task.contract.provider?.name || task.contract.providerName || 'Kein Anbieter'}`
: `Vertrag #${task.contractId}`;
const customerDisplay = task.contract?.customer
? (task.contract.customer.companyName || `${task.contract.customer.firstName} ${task.contract.customer.lastName}`)
: '';
return (
<div key={task.id} className="border rounded-lg mb-2">
{/* Task Header */}
<div
className="flex items-center p-4 hover:bg-gray-50 cursor-pointer"
onClick={() => toggleExpanded(task.id)}
>
{/* Expand Button */}
<div className="w-6 mr-2">
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-gray-400" />
) : (
<ChevronRight className="w-5 h-5 text-gray-400" />
)}
</div>
{/* Status Icon */}
<div className="mr-3">
{task.status === 'COMPLETED' ? (
<CheckCircle className="w-5 h-5 text-green-500" />
) : (
<Clock className="w-5 h-5 text-yellow-500" />
)}
</div>
{/* Task Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{task.title}</span>
<Badge variant={statusVariants[task.status]}>
{statusLabels[task.status]}
</Badge>
{hasSubtasks && (
<span className="text-xs text-gray-500">
({completedSubtasks}/{totalSubtasks} erledigt)
</span>
)}
</div>
<div className="text-sm text-gray-500 mt-1 flex items-center gap-2">
<FileText className="w-4 h-4" />
<Link
to={`/contracts/${task.contractId}`}
className="text-blue-600 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{contractDisplay}
</Link>
{showCustomer && customerDisplay && (
<>
<span className="text-gray-400">|</span>
<span>{customerDisplay}</span>
</>
)}
</div>
{task.description && (
<p className="text-sm text-gray-600 mt-1 line-clamp-2">{task.description}</p>
)}
</div>
{/* Actions */}
<div className="ml-4 flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
navigate(`/contracts/${task.contractId}`);
}}
title="Zum Vertrag"
>
<Eye className="w-4 h-4" />
</Button>
</div>
</div>
{/* Expanded Content: Subtasks + Reply */}
{isExpanded && (
<div className="border-t bg-gray-50 px-4 py-3">
{/* Subtasks */}
{hasSubtasks && (
<div className="space-y-2 mb-4">
{task.subtasks?.map((subtask) => {
const createdDate = new Date(subtask.createdAt).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
return (
<div
key={subtask.id}
className={`flex items-start gap-2 text-sm ml-6 ${canEditSubtasks ? 'cursor-pointer hover:bg-gray-100 rounded px-2 py-1 -mx-2' : ''}`}
onClick={canEditSubtasks ? () => handleSubtaskToggle(subtask) : undefined}
>
{/* Radio-Button Style Icon */}
<span className="flex-shrink-0 mt-0.5">
{subtask.status === 'COMPLETED' ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : (
<Circle className="w-4 h-4 text-gray-400" />
)}
</span>
<span className={subtask.status === 'COMPLETED' ? 'text-gray-500 line-through' : ''}>
{subtask.title}
<span className="text-xs text-gray-400 ml-2">
{subtask.createdBy} {createdDate}
</span>
</span>
</div>
);
})}
</div>
)}
{/* Reply Input - nur anzeigen wenn Task noch offen */}
{!isTaskCompleted && (canEditSubtasks || isCustomerPortal) && (
<div className="flex gap-2 ml-6">
<Input
placeholder={isCustomerPortal ? 'Antwort schreiben...' : 'Neue Unteraufgabe...'}
value={replyInputs[task.id] || ''}
onChange={(e) => setReplyInputs(prev => ({ ...prev, [task.id]: e.target.value }))}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleAddReply(task.id);
}
}}
className="flex-1"
/>
<Button
size="sm"
onClick={() => handleAddReply(task.id)}
disabled={!replyInputs[task.id]?.trim() || createSubtaskMutation.isPending}
>
<Send className="w-4 h-4" />
</Button>
</div>
)}
{/* Keine Subtasks Nachricht */}
{!hasSubtasks && isTaskCompleted && (
<p className="text-gray-500 text-sm text-center py-2">
Keine Unteraufgaben vorhanden.
</p>
)}
</div>
)}
</div>
);
};
// Kann der Benutzer Aufgaben erstellen?
// Für Kundenportal: Nur wenn Setting aktiviert UND geladen ist
const canCreateTask = isCustomerPortal
? supportTicketsEnabled
: hasPermission('contracts:update');
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">{pageTitle}</h1>
{canCreateTask && (
<Button onClick={() => setShowCreateModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Neue {taskLabel}
</Button>
)}
</div>
{/* Filter */}
<Card className="mb-6">
<div className="flex gap-4 flex-wrap items-center">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Status:</span>
<Select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
options={[
{ value: '', label: 'Alle' },
...Object.entries(statusLabels).map(([value, label]) => ({ value, label }))
]}
className="w-40"
/>
</div>
</div>
</Card>
{isLoading ? (
<Card>
<div className="text-center py-8 text-gray-500">Laden...</div>
</Card>
) : (
<>
{/* Kundenportal: Gruppierte Ansicht */}
{isCustomerPortal ? (
<div className="space-y-6">
{/* Eigene Anfragen */}
<Card>
<div className="flex items-center gap-3 mb-4 pb-3 border-b">
<User className="w-5 h-5 text-blue-600" />
<h2 className="text-lg font-semibold text-gray-900">Meine {pageTitle}</h2>
<Badge variant="default">{groupedTasks.ownTasks.length}</Badge>
</div>
{groupedTasks.ownTasks.length > 0 ? (
<div>
{groupedTasks.ownTasks.map((task) => renderTask(task, false))}
</div>
) : (
<p className="text-gray-500 text-center py-4">
Keine eigenen {pageTitle.toLowerCase()} vorhanden.
</p>
)}
</Card>
{/* Fremd-Anfragen - nur anzeigen wenn vorhanden */}
{groupedTasks.representedTasks.length > 0 && (
<Card>
<div className="flex items-center gap-3 mb-4 pb-3 border-b">
<Users className="w-5 h-5 text-purple-600" />
<h2 className="text-lg font-semibold text-gray-900">
{pageTitle} freigegebener Kunden
</h2>
<Badge variant="default">{groupedTasks.representedTasks.length}</Badge>
</div>
<div>
{groupedTasks.representedTasks.map((task) => renderTask(task, true))}
</div>
</Card>
)}
</div>
) : (
/* Mitarbeiter-Ansicht */
<Card>
{groupedTasks.allTasks && groupedTasks.allTasks.length > 0 ? (
<div>
{groupedTasks.allTasks.map((task) => renderTask(task, true))}
</div>
) : (
<div className="text-center py-8 text-gray-500">
Keine {pageTitle.toLowerCase()} gefunden.
</div>
)}
</Card>
)}
</>
)}
{/* Modal für neue Aufgabe/Support-Anfrage */}
{isCustomerPortal ? (
<CreateSupportTicketModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
/>
) : (
<CreateTaskModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
/>
)}
</div>
);
}
// Modal für neue Support-Anfrage (Kundenportal)
function CreateSupportTicketModal({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) {
const { user } = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [customerFilter, setCustomerFilter] = useState<'own' | number>('own');
const [selectedContractId, setSelectedContractId] = useState<number | null>(null);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [contractSearch, setContractSearch] = useState('');
// Lade alle Verträge des Benutzers (eigene + freigegebene)
const { data: contractsData } = useQuery({
queryKey: ['contracts', user?.customerId],
queryFn: () => contractApi.getAll({ customerId: user?.customerId }),
enabled: isOpen,
});
// Gruppiere Verträge nach Kunde
const groupedContracts = useMemo(() => {
if (!contractsData?.data) return { own: [], represented: {} as Record<number, { name: string; contracts: Contract[] }> };
const own: Contract[] = [];
const represented: Record<number, { name: string; contracts: Contract[] }> = {};
for (const contract of contractsData.data) {
if (contract.customerId === user?.customerId) {
own.push(contract);
} else {
if (!represented[contract.customerId]) {
const name = contract.customer
? (contract.customer.companyName || `${contract.customer.firstName} ${contract.customer.lastName}`)
: `Kunde ${contract.customerId}`;
represented[contract.customerId] = { name, contracts: [] };
}
represented[contract.customerId].contracts.push(contract);
}
}
return { own, represented };
}, [contractsData?.data, user?.customerId]);
// Hat der Benutzer freigegebene Kunden?
const hasRepresentedCustomers = Object.keys(groupedContracts.represented).length > 0;
// Aktuelle Verträge basierend auf Kundenfilter
const currentContracts = useMemo(() => {
if (customerFilter === 'own') {
return groupedContracts.own;
}
return groupedContracts.represented[customerFilter]?.contracts || [];
}, [customerFilter, groupedContracts]);
// Gefilterte Verträge basierend auf Suche
const filteredContracts = useMemo(() => {
if (!contractSearch) return currentContracts;
const search = contractSearch.toLowerCase();
return currentContracts.filter(c =>
c.contractNumber.toLowerCase().includes(search) ||
(c.providerName || '').toLowerCase().includes(search) ||
(c.tariffName || '').toLowerCase().includes(search)
);
}, [currentContracts, contractSearch]);
const handleSubmit = async () => {
if (!selectedContractId || !title.trim()) return;
setIsSubmitting(true);
try {
await contractTaskApi.createSupportTicket(selectedContractId, {
title: title.trim(),
description: description.trim() || undefined,
});
queryClient.invalidateQueries({ queryKey: ['all-tasks'] });
queryClient.invalidateQueries({ queryKey: ['task-stats'] });
onClose();
// Reset form
setTitle('');
setDescription('');
setSelectedContractId(null);
setCustomerFilter('own');
// Navigate to the contract
navigate(`/contracts/${selectedContractId}`);
} catch (error) {
console.error('Fehler beim Erstellen der Support-Anfrage:', error);
alert('Fehler beim Erstellen der Support-Anfrage. Bitte versuchen Sie es erneut.');
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
setTitle('');
setDescription('');
setSelectedContractId(null);
setCustomerFilter('own');
setContractSearch('');
onClose();
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Neue Support-Anfrage"
>
<div className="space-y-4">
{/* Kundenauswahl (nur wenn freigegebene Kunden vorhanden) */}
{hasRepresentedCustomers && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kunde
</label>
<select
value={customerFilter}
onChange={(e) => {
const val = e.target.value;
setCustomerFilter(val === 'own' ? 'own' : parseInt(val));
setSelectedContractId(null);
setContractSearch('');
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="own">Eigene Verträge</option>
{Object.entries(groupedContracts.represented).map(([id, { name }]) => (
<option key={id} value={id}>{name}</option>
))}
</select>
</div>
)}
{/* Vertragsauswahl */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Vertrag *
</label>
<Input
placeholder="Vertrag suchen..."
value={contractSearch}
onChange={(e) => setContractSearch(e.target.value)}
className="mb-2"
/>
<div className="max-h-48 overflow-y-auto border rounded-lg">
{filteredContracts.length > 0 ? (
filteredContracts.map((contract) => (
<div
key={contract.id}
onClick={() => setSelectedContractId(contract.id)}
className={`p-3 cursor-pointer border-b last:border-b-0 hover:bg-gray-50 ${
selectedContractId === contract.id ? 'bg-blue-50 border-blue-200' : ''
}`}
>
<div className="font-medium">{contract.contractNumber}</div>
<div className="text-sm text-gray-500">
{contract.providerName || 'Kein Anbieter'}
{contract.tariffName && ` - ${contract.tariffName}`}
</div>
</div>
))
) : (
<div className="p-3 text-gray-500 text-center">
Keine Verträge gefunden.
</div>
)}
</div>
</div>
{/* Titel */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Titel *
</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Kurze Beschreibung Ihres Anliegens"
/>
</div>
{/* Beschreibung */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Beschreibung
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Detaillierte Beschreibung (optional)"
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Buttons */}
<div className="flex justify-end gap-2 pt-4">
<Button variant="secondary" onClick={handleClose}>
Abbrechen
</Button>
<Button
onClick={handleSubmit}
disabled={!selectedContractId || !title.trim() || isSubmitting}
>
{isSubmitting ? 'Wird erstellt...' : 'Anfrage erstellen'}
</Button>
</div>
</div>
</Modal>
);
}
// Modal für neue Aufgabe (Mitarbeiter/Admin)
function CreateTaskModal({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [selectedCustomerId, setSelectedCustomerId] = useState<number | null>(null);
const [selectedContractId, setSelectedContractId] = useState<number | null>(null);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [visibleInPortal, setVisibleInPortal] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [customerSearch, setCustomerSearch] = useState('');
const [contractSearch, setContractSearch] = useState('');
// Lade Kunden
const { data: customersData } = useQuery({
queryKey: ['customers-for-task'],
queryFn: () => customerApi.getAll({ limit: 100 }),
enabled: isOpen,
});
// Lade Verträge des ausgewählten Kunden
const { data: contractsData } = useQuery({
queryKey: ['contracts-for-task', selectedCustomerId],
queryFn: () => contractApi.getAll({ customerId: selectedCustomerId! }),
enabled: isOpen && selectedCustomerId !== null,
});
// Gefilterte Kunden
const filteredCustomers = useMemo(() => {
if (!customersData?.data) return [];
if (!customerSearch) return customersData.data;
const search = customerSearch.toLowerCase();
return customersData.data.filter(c =>
c.customerNumber.toLowerCase().includes(search) ||
c.firstName.toLowerCase().includes(search) ||
c.lastName.toLowerCase().includes(search) ||
(c.companyName || '').toLowerCase().includes(search)
);
}, [customersData?.data, customerSearch]);
// Gefilterte Verträge
const filteredContracts = useMemo(() => {
if (!contractsData?.data) return [];
if (!contractSearch) return contractsData.data;
const search = contractSearch.toLowerCase();
return contractsData.data.filter(c =>
c.contractNumber.toLowerCase().includes(search) ||
(c.providerName || '').toLowerCase().includes(search) ||
(c.tariffName || '').toLowerCase().includes(search)
);
}, [contractsData?.data, contractSearch]);
const handleSubmit = async () => {
if (!selectedContractId || !title.trim()) return;
setIsSubmitting(true);
try {
await contractTaskApi.create(selectedContractId, {
title: title.trim(),
description: description.trim() || undefined,
visibleInPortal,
});
queryClient.invalidateQueries({ queryKey: ['all-tasks'] });
queryClient.invalidateQueries({ queryKey: ['task-stats'] });
onClose();
// Reset form
setTitle('');
setDescription('');
setVisibleInPortal(false);
setSelectedContractId(null);
setSelectedCustomerId(null);
// Navigate to the contract
navigate(`/contracts/${selectedContractId}`);
} catch (error) {
console.error('Fehler beim Erstellen der Aufgabe:', error);
alert('Fehler beim Erstellen der Aufgabe. Bitte versuchen Sie es erneut.');
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
setTitle('');
setDescription('');
setVisibleInPortal(false);
setSelectedContractId(null);
setSelectedCustomerId(null);
setCustomerSearch('');
setContractSearch('');
onClose();
};
const getCustomerDisplay = (customer: Customer) => {
const name = customer.companyName || `${customer.firstName} ${customer.lastName}`;
return `${customer.customerNumber} - ${name}`;
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Neue Aufgabe"
>
<div className="space-y-4">
{/* Kundenauswahl */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kunde *
</label>
<Input
placeholder="Kunde suchen..."
value={customerSearch}
onChange={(e) => setCustomerSearch(e.target.value)}
className="mb-2"
/>
<div className="max-h-40 overflow-y-auto border rounded-lg">
{filteredCustomers.length > 0 ? (
filteredCustomers.map((customer) => (
<div
key={customer.id}
onClick={() => {
setSelectedCustomerId(customer.id);
setSelectedContractId(null);
setContractSearch('');
}}
className={`p-3 cursor-pointer border-b last:border-b-0 hover:bg-gray-50 ${
selectedCustomerId === customer.id ? 'bg-blue-50 border-blue-200' : ''
}`}
>
<div className="font-medium">{getCustomerDisplay(customer)}</div>
</div>
))
) : (
<div className="p-3 text-gray-500 text-center">
Keine Kunden gefunden.
</div>
)}
</div>
</div>
{/* Vertragsauswahl (nur wenn Kunde ausgewählt) */}
{selectedCustomerId && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Vertrag *
</label>
<Input
placeholder="Vertrag suchen..."
value={contractSearch}
onChange={(e) => setContractSearch(e.target.value)}
className="mb-2"
/>
<div className="max-h-40 overflow-y-auto border rounded-lg">
{filteredContracts.length > 0 ? (
filteredContracts.map((contract) => (
<div
key={contract.id}
onClick={() => setSelectedContractId(contract.id)}
className={`p-3 cursor-pointer border-b last:border-b-0 hover:bg-gray-50 ${
selectedContractId === contract.id ? 'bg-blue-50 border-blue-200' : ''
}`}
>
<div className="font-medium">{contract.contractNumber}</div>
<div className="text-sm text-gray-500">
{contract.providerName || 'Kein Anbieter'}
{contract.tariffName && ` - ${contract.tariffName}`}
</div>
</div>
))
) : (
<div className="p-3 text-gray-500 text-center">
{contractsData ? 'Keine Verträge gefunden.' : 'Laden...'}
</div>
)}
</div>
</div>
)}
{/* Titel */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Titel *
</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Aufgabentitel"
/>
</div>
{/* Beschreibung */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Beschreibung
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Detaillierte Beschreibung (optional)"
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Im Kundenportal sichtbar */}
<div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={visibleInPortal}
onChange={(e) => setVisibleInPortal(e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">Im Kundenportal sichtbar</span>
</label>
</div>
{/* Buttons */}
<div className="flex justify-end gap-2 pt-4">
<Button variant="secondary" onClick={handleClose}>
Abbrechen
</Button>
<Button
onClick={handleSubmit}
disabled={!selectedContractId || !title.trim() || isSubmitting}
>
{isSubmitting ? 'Wird erstellt...' : 'Aufgabe erstellen'}
</Button>
</div>
</div>
</Modal>
);
}
+425
View File
@@ -0,0 +1,425 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { userApi } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Modal from '../../components/ui/Modal';
import Badge from '../../components/ui/Badge';
import { Plus, Edit, Trash2, Search, Code, AlertTriangle, ArrowLeft } from 'lucide-react';
import { Link } from 'react-router-dom';
import type { User, Role } from '../../types';
export default function UserList() {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [showModal, setShowModal] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const queryClient = useQueryClient();
const { refreshUser } = useAuth();
const { data, isLoading } = useQuery({
queryKey: ['users', search, page],
queryFn: () => userApi.getAll({ search: search || undefined, page, limit: 20 }),
});
const { data: rolesData } = useQuery({
queryKey: ['roles'],
queryFn: () => userApi.getRoles(),
});
const deleteMutation = useMutation({
mutationFn: userApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: (error: any) => {
alert(error?.message || 'Fehler beim Löschen des Benutzers');
},
});
// Check if user is admin (has Admin role)
const isUserAdmin = (user: User) => {
return user.roles?.some((role: any) => role.name === 'Admin');
};
// Count active admins
const activeAdminCount = data?.data?.filter(
(u) => (u as any).isActive && isUserAdmin(u)
).length || 0;
const handleEdit = (user: User) => {
setEditingUser(user);
setShowModal(true);
};
const handleClose = () => {
setShowModal(false);
setEditingUser(null);
};
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Link to="/settings">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold flex-1">Benutzer</h1>
<Button onClick={() => setShowModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Neuer Benutzer
</Button>
</div>
<Card className="mb-6">
<div className="flex gap-4">
<div className="flex-1">
<Input
placeholder="Suchen..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Button variant="secondary">
<Search className="w-4 h-4" />
</Button>
</div>
</Card>
<Card>
{isLoading ? (
<div className="text-center py-8 text-gray-500">Laden...</div>
) : data?.data && data.data.length > 0 ? (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4 font-medium text-gray-600">Name</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">E-Mail</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Rollen</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Status</th>
<th className="text-right py-3 px-4 font-medium text-gray-600">Aktionen</th>
</tr>
</thead>
<tbody>
{data.data.map((user) => (
<tr key={user.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4">
{user.firstName} {user.lastName}
</td>
<td className="py-3 px-4">{user.email}</td>
<td className="py-3 px-4">
<div className="flex gap-1 flex-wrap">
{user.roles?.filter((role: any) => role.name !== 'Developer').map((role: any) => (
<Badge key={role.id || role.name} variant="info">
{role.name}
</Badge>
))}
</div>
</td>
<td className="py-3 px-4">
<div className="flex gap-2">
<Badge variant={(user as any).isActive ? 'success' : 'danger'}>
{(user as any).isActive ? 'Aktiv' : 'Inaktiv'}
</Badge>
{(user as any).hasDeveloperAccess && (
<Badge variant="warning" className="flex items-center gap-1">
<Code className="w-3 h-3" />
Dev
</Badge>
)}
</div>
</td>
<td className="py-3 px-4 text-right">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => handleEdit(user)}>
<Edit className="w-4 h-4" />
</Button>
{(() => {
const isLastAdmin = isUserAdmin(user) && (user as any).isActive && activeAdminCount <= 1;
return (
<Button
variant="ghost"
size="sm"
disabled={isLastAdmin}
title={isLastAdmin ? 'Letzter Administrator kann nicht gelöscht werden' : undefined}
onClick={() => {
if (confirm('Benutzer wirklich löschen?')) {
deleteMutation.mutate(user.id);
}
}}
>
<Trash2 className={`w-4 h-4 ${isLastAdmin ? 'text-gray-300' : 'text-red-500'}`} />
</Button>
);
})()}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{data.pagination && data.pagination.totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-gray-500">
Seite {data.pagination.page} von {data.pagination.totalPages}
</p>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
Zurück
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setPage((p) => p + 1)}
disabled={page >= data.pagination.totalPages}
>
Weiter
</Button>
</div>
</div>
)}
</>
) : (
<div className="text-center py-8 text-gray-500">Keine Benutzer gefunden.</div>
)}
</Card>
<UserModal
isOpen={showModal}
onClose={handleClose}
user={editingUser}
roles={rolesData?.data || []}
onUserUpdated={refreshUser}
/>
</div>
);
}
function UserModal({
isOpen,
onClose,
user,
roles,
onUserUpdated,
}: {
isOpen: boolean;
onClose: () => void;
user: User | null;
roles: Role[];
onUserUpdated: () => Promise<void>;
}) {
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({
email: '',
password: '',
firstName: '',
lastName: '',
roleIds: [] as number[],
isActive: true,
hasDeveloperAccess: false,
});
// Reset form when modal opens or user changes
useEffect(() => {
if (isOpen) {
setError(null);
if (user) {
setFormData({
email: user.email,
password: '',
firstName: user.firstName,
lastName: user.lastName,
roleIds: user.roles?.filter((r: any) => r.name !== 'Developer').map((r: any) => r.id) || [],
isActive: (user as any).isActive ?? true,
hasDeveloperAccess: (user as any).hasDeveloperAccess ?? false,
});
} else {
setFormData({
email: '',
password: '',
firstName: '',
lastName: '',
roleIds: [],
isActive: true,
hasDeveloperAccess: false,
});
}
}
}, [isOpen, user]);
const createMutation = useMutation({
mutationFn: userApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
onClose();
},
onError: (err: any) => {
setError(err?.message || 'Fehler beim Erstellen des Benutzers');
},
});
const updateMutation = useMutation({
mutationFn: (data: any) => userApi.update(user!.id, data),
onSuccess: async () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
await onUserUpdated(); // Refresh current user's permissions and wait for it
onClose();
},
onError: (err: any) => {
setError(err?.message || 'Fehler beim Aktualisieren des Benutzers');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (user) {
const updateData: any = {
email: formData.email,
firstName: formData.firstName,
lastName: formData.lastName,
roleIds: formData.roleIds,
isActive: formData.isActive,
hasDeveloperAccess: formData.hasDeveloperAccess,
};
if (formData.password) {
updateData.password = formData.password;
}
updateMutation.mutate(updateData);
} else {
createMutation.mutate({
email: formData.email,
password: formData.password,
firstName: formData.firstName,
lastName: formData.lastName,
roleIds: formData.roleIds,
});
}
};
const handleRoleToggle = (roleId: number) => {
setFormData((prev) => ({
...prev,
roleIds: prev.roleIds.includes(roleId)
? prev.roleIds.filter((id) => id !== roleId)
: [...prev.roleIds, roleId],
}));
};
const isLoading = createMutation.isPending || updateMutation.isPending;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={user ? 'Benutzer bearbeiten' : 'Neuer Benutzer'}
>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 flex items-start gap-2">
<AlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<Input
label="Vorname *"
value={formData.firstName}
onChange={(e) => setFormData({ ...formData, firstName: e.target.value })}
required
/>
<Input
label="Nachname *"
value={formData.lastName}
onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
required
/>
</div>
<Input
label="E-Mail *"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
/>
<Input
label={user ? 'Neues Passwort (leer = unverändert)' : 'Passwort *'}
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required={!user}
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Rollen</label>
<div className="space-y-2">
{roles.filter((role) => role.name !== 'Developer').map((role) => (
<label key={role.id} className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.roleIds.includes(role.id)}
onChange={() => handleRoleToggle(role.id)}
className="rounded"
/>
<span>{role.name}</span>
{role.description && (
<span className="text-sm text-gray-500">({role.description})</span>
)}
</label>
))}
</div>
</div>
{user && (
<div className="space-y-3 pt-3 border-t">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded"
/>
Aktiv
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.hasDeveloperAccess}
onChange={(e) => setFormData({ ...formData, hasDeveloperAccess: e.target.checked })}
className="rounded border-purple-300 text-purple-600 focus:ring-purple-500"
/>
<span className="flex items-center gap-1">
<Code className="w-4 h-4 text-purple-600" />
Entwicklerzugriff
</span>
<span className="text-sm text-gray-500">(Datenbanktools)</span>
</label>
</div>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</Modal>
);
}
+773
View File
@@ -0,0 +1,773 @@
import axios from 'axios';
import type { ApiResponse, Customer, Contract, ContractTask, ContractTaskSubtask, ContractTaskStatus, SalesPlatform, CancellationPeriod, ContractDuration, ContractCategory, Provider, Tariff, User, Address, BankCard, IdentityDocument, Meter, MeterReading, Role, PortalSettings, CustomerRepresentative, CustomerSummary } from '../types';
const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
});
// Add auth token to requests
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle auth errors and extract error messages
api.interceptors.response.use(
(response) => response,
(error) => {
// Bei 401 nur dann zur Login-Seite umleiten, wenn wir NICHT gerade auf der Login-Seite sind
// Login-Endpunkte ausschließen, da 401 dort "falsches Passwort" bedeutet
const isLoginEndpoint = error.config?.url?.includes('/auth/login') ||
error.config?.url?.includes('/auth/customer-login');
if (error.response?.status === 401 && !isLoginEndpoint) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
// Extract error message from response
const message = error.response?.data?.error || error.message || 'Ein Fehler ist aufgetreten';
const enhancedError = new Error(message);
return Promise.reject(enhancedError);
}
);
// Auth
export const authApi = {
login: async (email: string, password: string) => {
const res = await api.post<ApiResponse<{ token: string; user: User }>>('/auth/login', { email, password });
return res.data;
},
customerLogin: async (email: string, password: string) => {
const res = await api.post<ApiResponse<{ token: string; user: User }>>('/auth/customer-login', { email, password });
return res.data;
},
me: async () => {
const res = await api.get<ApiResponse<User>>('/auth/me');
return res.data;
},
};
// Customers
export const customerApi = {
getAll: async (params?: { search?: string; type?: string; page?: number; limit?: number }) => {
const res = await api.get<ApiResponse<Customer[]>>('/customers', { params });
return res.data;
},
getById: async (id: number) => {
const res = await api.get<ApiResponse<Customer>>(`/customers/${id}`);
return res.data;
},
create: async (data: Partial<Customer>) => {
const res = await api.post<ApiResponse<Customer>>('/customers', data);
return res.data;
},
update: async (id: number, data: Partial<Customer>) => {
const res = await api.put<ApiResponse<Customer>>(`/customers/${id}`, data);
return res.data;
},
delete: async (id: number) => {
const res = await api.delete<ApiResponse<void>>(`/customers/${id}`);
return res.data;
},
// Portal-Einstellungen
getPortalSettings: async (customerId: number) => {
const res = await api.get<ApiResponse<PortalSettings>>(`/customers/${customerId}/portal`);
return res.data;
},
updatePortalSettings: async (customerId: number, data: { portalEnabled?: boolean; portalEmail?: string | null }) => {
const res = await api.put<ApiResponse<PortalSettings>>(`/customers/${customerId}/portal`, data);
return res.data;
},
setPortalPassword: async (customerId: number, password: string) => {
const res = await api.post<ApiResponse<void>>(`/customers/${customerId}/portal/password`, { password });
return res.data;
},
getPortalPassword: async (customerId: number) => {
const res = await api.get<ApiResponse<{ password: string | null }>>(`/customers/${customerId}/portal/password`);
return res.data;
},
// Vertreter-Verwaltung
getRepresentatives: async (customerId: number) => {
const res = await api.get<ApiResponse<CustomerRepresentative[]>>(`/customers/${customerId}/representatives`);
return res.data;
},
addRepresentative: async (customerId: number, representativeId: number, notes?: string) => {
const res = await api.post<ApiResponse<CustomerRepresentative>>(`/customers/${customerId}/representatives`, { representativeId, notes });
return res.data;
},
removeRepresentative: async (customerId: number, representativeId: number) => {
const res = await api.delete<ApiResponse<void>>(`/customers/${customerId}/representatives/${representativeId}`);
return res.data;
},
searchForRepresentative: async (customerId: number, search: string) => {
const res = await api.get<ApiResponse<CustomerSummary[]>>(`/customers/${customerId}/representatives/search`, { params: { search } });
return res.data;
},
};
// Addresses
export const addressApi = {
getByCustomer: async (customerId: number) => {
const res = await api.get<ApiResponse<Address[]>>(`/customers/${customerId}/addresses`);
return res.data;
},
create: async (customerId: number, data: Partial<Address>) => {
const res = await api.post<ApiResponse<Address>>(`/customers/${customerId}/addresses`, data);
return res.data;
},
update: async (id: number, data: Partial<Address>) => {
const res = await api.put<ApiResponse<Address>>(`/addresses/${id}`, data);
return res.data;
},
delete: async (id: number) => {
const res = await api.delete<ApiResponse<void>>(`/addresses/${id}`);
return res.data;
},
};
// Bank Cards
export const bankCardApi = {
getByCustomer: async (customerId: number, showInactive = false) => {
const res = await api.get<ApiResponse<BankCard[]>>(`/customers/${customerId}/bank-cards`, { params: { showInactive } });
return res.data;
},
create: async (customerId: number, data: Partial<BankCard>) => {
const res = await api.post<ApiResponse<BankCard>>(`/customers/${customerId}/bank-cards`, data);
return res.data;
},
update: async (id: number, data: Partial<BankCard>) => {
const res = await api.put<ApiResponse<BankCard>>(`/bank-cards/${id}`, data);
return res.data;
},
delete: async (id: number) => {
const res = await api.delete<ApiResponse<void>>(`/bank-cards/${id}`);
return res.data;
},
};
// Identity Documents
export const documentApi = {
getByCustomer: async (customerId: number, showInactive = false) => {
const res = await api.get<ApiResponse<IdentityDocument[]>>(`/customers/${customerId}/documents`, { params: { showInactive } });
return res.data;
},
create: async (customerId: number, data: Partial<IdentityDocument>) => {
const res = await api.post<ApiResponse<IdentityDocument>>(`/customers/${customerId}/documents`, data);
return res.data;
},
update: async (id: number, data: Partial<IdentityDocument>) => {
const res = await api.put<ApiResponse<IdentityDocument>>(`/documents/${id}`, data);
return res.data;
},
delete: async (id: number) => {
const res = await api.delete<ApiResponse<void>>(`/documents/${id}`);
return res.data;
},
};
// Meters
export const meterApi = {
getByCustomer: async (customerId: number, showInactive = false) => {
const res = await api.get<ApiResponse<Meter[]>>(`/customers/${customerId}/meters`, { params: { showInactive } });
return res.data;
},
create: async (customerId: number, data: Partial<Meter>) => {
const res = await api.post<ApiResponse<Meter>>(`/customers/${customerId}/meters`, data);
return res.data;
},
update: async (id: number, data: Partial<Meter>) => {
const res = await api.put<ApiResponse<Meter>>(`/meters/${id}`, data);
return res.data;
},
delete: async (id: number) => {
const res = await api.delete<ApiResponse<void>>(`/meters/${id}`);
return res.data;
},
getReadings: async (meterId: number) => {
const res = await api.get<ApiResponse<MeterReading[]>>(`/meters/${meterId}/readings`);
return res.data;
},
addReading: async (meterId: number, data: Partial<MeterReading>) => {
const res = await api.post<ApiResponse<MeterReading>>(`/meters/${meterId}/readings`, data);
return res.data;
},
updateReading: async (meterId: number, readingId: number, data: Partial<MeterReading>) => {
const res = await api.put<ApiResponse<MeterReading>>(`/meters/${meterId}/readings/${readingId}`, data);
return res.data;
},
deleteReading: async (meterId: number, readingId: number) => {
const res = await api.delete<ApiResponse<void>>(`/meters/${meterId}/readings/${readingId}`);
return res.data;
},
};
// Stressfrei-Wechseln E-Mail-Adressen
export interface StressfreiEmail {
id: number;
customerId: number;
email: string;
platform?: string;
notes?: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export const stressfreiEmailApi = {
getByCustomer: async (customerId: number, includeInactive = false) => {
const res = await api.get<ApiResponse<StressfreiEmail[]>>(`/customers/${customerId}/stressfrei-emails`, { params: { includeInactive } });
return res.data;
},
create: async (customerId: number, data: { email: string; platform?: string; notes?: string }) => {
const res = await api.post<ApiResponse<StressfreiEmail>>(`/customers/${customerId}/stressfrei-emails`, data);
return res.data;
},
update: async (id: number, data: Partial<StressfreiEmail>) => {
const res = await api.put<ApiResponse<StressfreiEmail>>(`/stressfrei-emails/${id}`, data);
return res.data;
},
delete: async (id: number) => {
const res = await api.delete<ApiResponse<void>>(`/stressfrei-emails/${id}`);
return res.data;
},
};
// Contracts
export const contractApi = {
getAll: async (params?: { customerId?: number; type?: string; status?: string; search?: string; page?: number; limit?: number }) => {
const res = await api.get<ApiResponse<Contract[]>>('/contracts', { params });
return res.data;
},
getById: async (id: number) => {
const res = await api.get<ApiResponse<Contract>>(`/contracts/${id}`);
return res.data;
},
create: async (data: Partial<Contract> & { [key: string]: unknown }) => {
const res = await api.post<ApiResponse<Contract>>('/contracts', data);
return res.data;
},
update: async (id: number, data: Partial<Contract> & { [key: string]: unknown }) => {
const res = await api.put<ApiResponse<Contract>>(`/contracts/${id}`, data);
return res.data;
},
delete: async (id: number) => {
const res = await api.delete<ApiResponse<void>>(`/contracts/${id}`);
return res.data;
},
createFollowUp: async (id: number) => {
const res = await api.post<ApiResponse<Contract>>(`/contracts/${id}/follow-up`);
return res.data;
},
getPassword: async (id: number) => {
const res = await api.get<ApiResponse<{ password: string }>>(`/contracts/${id}/password`);
return res.data;
},
getSimCardCredentials: async (simCardId: number) => {
const res = await api.get<ApiResponse<{ pin: string | null; puk: string | null }>>(`/contracts/simcard/${simCardId}/credentials`);
return res.data;
},
getInternetCredentials: async (contractId: number) => {
const res = await api.get<ApiResponse<{ password: string | null }>>(`/contracts/${contractId}/internet-credentials`);
return res.data;
},
getSipCredentials: async (phoneNumberId: number) => {
const res = await api.get<ApiResponse<{ password: string | null }>>(`/contracts/phonenumber/${phoneNumberId}/sip-credentials`);
return res.data;
},
// Vertrags-Cockpit
getCockpit: async () => {
const res = await api.get<ApiResponse<import('../types').CockpitResult>>('/contracts/cockpit');
return res.data;
},
};
// Contract Tasks (Aufgaben)
export const contractTaskApi = {
// Alle Tasks über alle Verträge (für Task-Liste & Dashboard)
getAll: async (params?: { status?: ContractTaskStatus; customerId?: number }) => {
const res = await api.get<ApiResponse<ContractTask[]>>('/tasks', { params });
return res.data;
},
// Task-Statistik (offene Aufgaben)
getStats: async () => {
const res = await api.get<ApiResponse<{ openCount: number }>>('/tasks/stats');
return res.data;
},
// Tasks für einen spezifischen Vertrag
getByContract: async (contractId: number, status?: ContractTaskStatus) => {
const res = await api.get<ApiResponse<ContractTask[]>>(`/contracts/${contractId}/tasks`, { params: { status } });
return res.data;
},
create: async (contractId: number, data: { title: string; description?: string; visibleInPortal?: boolean }) => {
const res = await api.post<ApiResponse<ContractTask>>(`/contracts/${contractId}/tasks`, data);
return res.data;
},
update: async (taskId: number, data: { title?: string; description?: string; visibleInPortal?: boolean }) => {
const res = await api.put<ApiResponse<ContractTask>>(`/tasks/${taskId}`, data);
return res.data;
},
complete: async (taskId: number) => {
const res = await api.post<ApiResponse<ContractTask>>(`/tasks/${taskId}/complete`);
return res.data;
},
reopen: async (taskId: number) => {
const res = await api.post<ApiResponse<ContractTask>>(`/tasks/${taskId}/reopen`);
return res.data;
},
delete: async (taskId: number) => {
const res = await api.delete<ApiResponse<void>>(`/tasks/${taskId}`);
return res.data;
},
// Subtasks
createSubtask: async (taskId: number, title: string) => {
const res = await api.post<ApiResponse<ContractTaskSubtask>>(`/tasks/${taskId}/subtasks`, { title });
return res.data;
},
// Kundenportal: Antwort auf eigenes Ticket
createReply: async (taskId: number, title: string) => {
const res = await api.post<ApiResponse<ContractTaskSubtask>>(`/tasks/${taskId}/reply`, { title });
return res.data;
},
updateSubtask: async (subtaskId: number, title: string) => {
const res = await api.put<ApiResponse<ContractTaskSubtask>>(`/subtasks/${subtaskId}`, { title });
return res.data;
},
completeSubtask: async (subtaskId: number) => {
const res = await api.post<ApiResponse<ContractTaskSubtask>>(`/subtasks/${subtaskId}/complete`);
return res.data;
},
reopenSubtask: async (subtaskId: number) => {
const res = await api.post<ApiResponse<ContractTaskSubtask>>(`/subtasks/${subtaskId}/reopen`);
return res.data;
},
deleteSubtask: async (subtaskId: number) => {
const res = await api.delete<ApiResponse<void>>(`/subtasks/${subtaskId}`);
return res.data;
},
// Support-Ticket erstellen (für Kundenportal)
createSupportTicket: async (contractId: number, data: { title: string; description?: string }) => {
const res = await api.post<ApiResponse<ContractTask>>(`/contracts/${contractId}/support-ticket`, data);
return res.data;
},
};
// App Settings
export const appSettingsApi = {
getPublic: async () => {
const res = await api.get<ApiResponse<Record<string, string>>>('/settings/public');
return res.data;
},
getAll: async () => {
const res = await api.get<ApiResponse<Record<string, string>>>('/settings');
return res.data;
},
update: async (settings: Record<string, string>) => {
const res = await api.put<ApiResponse<void>>('/settings', settings);
return res.data;
},
updateOne: async (key: string, value: string) => {
const res = await api.put<ApiResponse<void>>(`/settings/${key}`, { value });
return res.data;
},
};
// Platforms
export const platformApi = {
getAll: async (includeInactive = false) => {
const res = await api.get<ApiResponse<SalesPlatform[]>>('/platforms', { params: { includeInactive } });
return res.data;
},
getById: async (id: number) => {
const res = await api.get<ApiResponse<SalesPlatform>>(`/platforms/${id}`);
return res.data;
},
create: async (data: Partial<SalesPlatform>) => {
const res = await api.post<ApiResponse<SalesPlatform>>('/platforms', data);
return res.data;
},
update: async (id: number, data: Partial<SalesPlatform>) => {
const res = await api.put<ApiResponse<SalesPlatform>>(`/platforms/${id}`, data);
return res.data;
},
delete: async (id: number) => {
const res = await api.delete<ApiResponse<void>>(`/platforms/${id}`);
return res.data;
},
};
// Cancellation Periods
export const cancellationPeriodApi = {
getAll: async (includeInactive = false) => {
const res = await api.get<ApiResponse<CancellationPeriod[]>>('/cancellation-periods', { params: { includeInactive } });
return res.data;
},
getById: async (id: number) => {
const res = await api.get<ApiResponse<CancellationPeriod>>(`/cancellation-periods/${id}`);
return res.data;
},
create: async (data: Partial<CancellationPeriod>) => {
const res = await api.post<ApiResponse<CancellationPeriod>>('/cancellation-periods', data);
return res.data;
},
update: async (id: number, data: Partial<CancellationPeriod>) => {
const res = await api.put<ApiResponse<CancellationPeriod>>(`/cancellation-periods/${id}`, data);
return res.data;
},
delete: async (id: number) => {
const res = await api.delete<ApiResponse<void>>(`/cancellation-periods/${id}`);
return res.data;
},
};
// Contract Durations
export const contractDurationApi = {
getAll: async (includeInactive = false) => {
const res = await api.get<ApiResponse<ContractDuration[]>>('/contract-durations', { params: { includeInactive } });
return res.data;
},
getById: async (id: number) => {
const res = await api.get<ApiResponse<ContractDuration>>(`/contract-durations/${id}`);
return res.data;
},
create: async (data: Partial<ContractDuration>) => {
const res = await api.post<ApiResponse<ContractDuration>>('/contract-durations', data);
return res.data;
},
update: async (id: number, data: Partial<ContractDuration>) => {
const res = await api.put<ApiResponse<ContractDuration>>(`/contract-durations/${id}`, data);
return res.data;
},
delete: async (id: number) => {
const res = await api.delete<ApiResponse<void>>(`/contract-durations/${id}`);
return res.data;
},
};
// Contract Categories (Vertragstypen)
export const contractCategoryApi = {
getAll: async (includeInactive = false) => {
const res = await api.get<ApiResponse<ContractCategory[]>>('/contract-categories', { params: { includeInactive } });
return res.data;
},
getById: async (id: number) => {
const res = await api.get<ApiResponse<ContractCategory>>(`/contract-categories/${id}`);
return res.data;
},
create: async (data: Partial<ContractCategory>) => {
const res = await api.post<ApiResponse<ContractCategory>>('/contract-categories', data);
return res.data;
},
update: async (id: number, data: Partial<ContractCategory>) => {
const res = await api.put<ApiResponse<ContractCategory>>(`/contract-categories/${id}`, data);
return res.data;
},
delete: async (id: number) => {
const res = await api.delete<ApiResponse<void>>(`/contract-categories/${id}`);
return res.data;
},
};
// Providers (Anbieter)
export const providerApi = {
getAll: async (includeInactive = false) => {
const res = await api.get<ApiResponse<Provider[]>>('/providers', { params: { includeInactive } });
return res.data;
},
getById: async (id: number) => {
const res = await api.get<ApiResponse<Provider>>(`/providers/${id}`);
return res.data;
},
create: async (data: Partial<Provider>) => {
const res = await api.post<ApiResponse<Provider>>('/providers', data);
return res.data;
},
update: async (id: number, data: Partial<Provider>) => {
const res = await api.put<ApiResponse<Provider>>(`/providers/${id}`, data);
return res.data;
},
delete: async (id: number) => {
const res = await api.delete<ApiResponse<void>>(`/providers/${id}`);
return res.data;
},
getTariffs: async (providerId: number, includeInactive = false) => {
const res = await api.get<ApiResponse<Tariff[]>>(`/providers/${providerId}/tariffs`, { params: { includeInactive } });
return res.data;
},
createTariff: async (providerId: number, data: Partial<Tariff>) => {
const res = await api.post<ApiResponse<Tariff>>(`/providers/${providerId}/tariffs`, data);
return res.data;
},
};
// Tariffs (Tarife)
export const tariffApi = {
getById: async (id: number) => {
const res = await api.get<ApiResponse<Tariff>>(`/tariffs/${id}`);
return res.data;
},
update: async (id: number, data: Partial<Tariff>) => {
const res = await api.put<ApiResponse<Tariff>>(`/tariffs/${id}`, data);
return res.data;
},
delete: async (id: number) => {
const res = await api.delete<ApiResponse<void>>(`/tariffs/${id}`);
return res.data;
},
};
// Upload
export const uploadApi = {
uploadBankCardDocument: async (bankCardId: number, file: File) => {
const formData = new FormData();
formData.append('document', file);
const res = await api.post<ApiResponse<{ path: string; filename: string }>>(`/upload/bank-cards/${bankCardId}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data;
},
uploadIdentityDocument: async (documentId: number, file: File) => {
const formData = new FormData();
formData.append('document', file);
const res = await api.post<ApiResponse<{ path: string; filename: string }>>(`/upload/documents/${documentId}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data;
},
deleteBankCardDocument: async (bankCardId: number) => {
const res = await api.delete<ApiResponse<void>>(`/upload/bank-cards/${bankCardId}`);
return res.data;
},
deleteIdentityDocument: async (documentId: number) => {
const res = await api.delete<ApiResponse<void>>(`/upload/documents/${documentId}`);
return res.data;
},
uploadBusinessRegistration: async (customerId: number, file: File) => {
const formData = new FormData();
formData.append('document', file);
const res = await api.post<ApiResponse<{ path: string; filename: string }>>(`/upload/customers/${customerId}/business-registration`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data;
},
deleteBusinessRegistration: async (customerId: number) => {
const res = await api.delete<ApiResponse<void>>(`/upload/customers/${customerId}/business-registration`);
return res.data;
},
uploadCommercialRegister: async (customerId: number, file: File) => {
const formData = new FormData();
formData.append('document', file);
const res = await api.post<ApiResponse<{ path: string; filename: string }>>(`/upload/customers/${customerId}/commercial-register`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data;
},
deleteCommercialRegister: async (customerId: number) => {
const res = await api.delete<ApiResponse<void>>(`/upload/customers/${customerId}/commercial-register`);
return res.data;
},
uploadPrivacyPolicy: async (customerId: number, file: File) => {
const formData = new FormData();
formData.append('document', file);
const res = await api.post<ApiResponse<{ path: string; filename: string }>>(`/upload/customers/${customerId}/privacy-policy`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data;
},
deletePrivacyPolicy: async (customerId: number) => {
const res = await api.delete<ApiResponse<void>>(`/upload/customers/${customerId}/privacy-policy`);
return res.data;
},
// Contract Documents
uploadCancellationLetter: async (contractId: number, file: File) => {
const formData = new FormData();
formData.append('document', file);
const res = await api.post<ApiResponse<{ path: string; filename: string }>>(`/upload/contracts/${contractId}/cancellation-letter`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data;
},
deleteCancellationLetter: async (contractId: number) => {
const res = await api.delete<ApiResponse<void>>(`/upload/contracts/${contractId}/cancellation-letter`);
return res.data;
},
uploadCancellationConfirmation: async (contractId: number, file: File) => {
const formData = new FormData();
formData.append('document', file);
const res = await api.post<ApiResponse<{ path: string; filename: string }>>(`/upload/contracts/${contractId}/cancellation-confirmation`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data;
},
deleteCancellationConfirmation: async (contractId: number) => {
const res = await api.delete<ApiResponse<void>>(`/upload/contracts/${contractId}/cancellation-confirmation`);
return res.data;
},
uploadCancellationLetterOptions: async (contractId: number, file: File) => {
const formData = new FormData();
formData.append('document', file);
const res = await api.post<ApiResponse<{ path: string; filename: string }>>(`/upload/contracts/${contractId}/cancellation-letter-options`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data;
},
deleteCancellationLetterOptions: async (contractId: number) => {
const res = await api.delete<ApiResponse<void>>(`/upload/contracts/${contractId}/cancellation-letter-options`);
return res.data;
},
uploadCancellationConfirmationOptions: async (contractId: number, file: File) => {
const formData = new FormData();
formData.append('document', file);
const res = await api.post<ApiResponse<{ path: string; filename: string }>>(`/upload/contracts/${contractId}/cancellation-confirmation-options`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data;
},
deleteCancellationConfirmationOptions: async (contractId: number) => {
const res = await api.delete<ApiResponse<void>>(`/upload/contracts/${contractId}/cancellation-confirmation-options`);
return res.data;
},
};
// Users
export const userApi = {
getAll: async (params?: { search?: string; isActive?: boolean; roleId?: number; page?: number; limit?: number }) => {
const res = await api.get<ApiResponse<User[]>>('/users', { params });
return res.data;
},
getById: async (id: number) => {
const res = await api.get<ApiResponse<User>>(`/users/${id}`);
return res.data;
},
create: async (data: { email: string; password: string; firstName: string; lastName: string; roleIds: number[]; customerId?: number }) => {
const res = await api.post<ApiResponse<User>>('/users', data);
return res.data;
},
update: async (id: number, data: Partial<User> & { password?: string; roleIds?: number[] }) => {
const res = await api.put<ApiResponse<User>>(`/users/${id}`, data);
return res.data;
},
delete: async (id: number) => {
const res = await api.delete<ApiResponse<void>>(`/users/${id}`);
return res.data;
},
getRoles: async () => {
const res = await api.get<ApiResponse<Role[]>>('/users/roles/list');
return res.data;
},
};
// Developer
export const developerApi = {
getSchema: async () => {
const res = await api.get<ApiResponse<any[]>>('/developer/schema');
return res.data;
},
getTableData: async (tableName: string, page = 1, limit = 50) => {
const res = await api.get<ApiResponse<any[]>>(`/developer/table/${tableName}`, { params: { page, limit } });
return res.data;
},
updateRow: async (tableName: string, id: string, data: Record<string, any>) => {
const res = await api.put<ApiResponse<any>>(`/developer/table/${tableName}/${id}`, data);
return res.data;
},
deleteRow: async (tableName: string, id: string) => {
const res = await api.delete<ApiResponse<void>>(`/developer/table/${tableName}/${id}`);
return res.data;
},
getReference: async (tableName: string) => {
const res = await api.get<ApiResponse<any[]>>(`/developer/reference/${tableName}`);
return res.data;
},
};
// Email Provider (für Stressfrei-Wechseln Provisionierung)
export interface EmailProviderConfig {
id: number;
name: string;
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
apiUrl: string;
apiKey?: string;
username?: string;
passwordEncrypted?: string;
domain: string;
defaultForwardEmail?: string;
isActive: boolean;
isDefault: boolean;
createdAt: string;
updatedAt: string;
}
export interface EmailOperationResult {
success: boolean;
message?: string;
error?: string;
}
export const emailProviderApi = {
// Config CRUD
getConfigs: async () => {
const res = await api.get<ApiResponse<EmailProviderConfig[]>>('/email-providers/configs');
return res.data;
},
getConfig: async (id: number) => {
const res = await api.get<ApiResponse<EmailProviderConfig>>(`/email-providers/configs/${id}`);
return res.data;
},
createConfig: async (data: Partial<EmailProviderConfig> & { password?: string }) => {
const res = await api.post<ApiResponse<EmailProviderConfig>>('/email-providers/configs', data);
return res.data;
},
updateConfig: async (id: number, data: Partial<EmailProviderConfig> & { password?: string }) => {
const res = await api.put<ApiResponse<EmailProviderConfig>>(`/email-providers/configs/${id}`, data);
return res.data;
},
deleteConfig: async (id: number) => {
const res = await api.delete<ApiResponse<void>>(`/email-providers/configs/${id}`);
return res.data;
},
// Email Operations
testConnection: async (options?: {
id?: number;
testData?: {
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
apiUrl: string;
apiKey?: string;
username?: string;
password?: string;
domain: string;
};
}) => {
const body = options?.testData
? { ...options.testData }
: options?.id
? { id: options.id }
: {};
const res = await api.post<ApiResponse<EmailOperationResult>>('/email-providers/test-connection', body);
return res.data;
},
getDomain: async () => {
const res = await api.get<ApiResponse<{ domain: string | null }>>('/email-providers/domain');
return res.data;
},
checkEmailExists: async (localPart: string) => {
const res = await api.get<ApiResponse<{ exists: boolean; email?: string }>>(`/email-providers/check/${localPart}`);
return res.data;
},
provisionEmail: async (localPart: string, customerEmail: string) => {
const res = await api.post<ApiResponse<EmailOperationResult>>('/email-providers/provision', { localPart, customerEmail });
return res.data;
},
deprovisionEmail: async (localPart: string) => {
const res = await api.delete<ApiResponse<EmailOperationResult>>(`/email-providers/deprovision/${localPart}`);
return res.data;
},
};
export default api;
+496
View File
@@ -0,0 +1,496 @@
export interface User {
id: number;
email: string;
firstName: string;
lastName: string;
permissions: string[];
customerId?: number;
roles?: Role[];
isCustomerPortal?: boolean;
representedCustomers?: CustomerSummary[];
}
// Zusammenfassung für Vertreter-Listen
export interface CustomerSummary {
id: number;
customerNumber: string;
firstName: string;
lastName: string;
companyName?: string;
type: 'PRIVATE' | 'BUSINESS';
}
export interface Role {
id: number;
name: string;
description?: string;
}
export interface StressfreiEmail {
id: number;
customerId: number;
email: string;
notes?: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface Customer {
id: number;
customerNumber: string;
type: 'PRIVATE' | 'BUSINESS';
salutation?: string;
firstName: string;
lastName: string;
companyName?: string;
foundingDate?: string;
birthDate?: string;
birthPlace?: string;
email?: string;
phone?: string;
mobile?: string;
taxNumber?: string;
businessRegistrationPath?: string;
commercialRegisterPath?: string;
commercialRegisterNumber?: string;
privacyPolicyPath?: string;
notes?: string;
// Portal-Felder
portalEnabled?: boolean;
portalEmail?: string;
portalLastLogin?: string;
addresses?: Address[];
bankCards?: BankCard[];
identityDocuments?: IdentityDocument[];
meters?: Meter[];
stressfreiEmails?: StressfreiEmail[];
contracts?: Contract[];
createdAt: string;
updatedAt: string;
}
// Portal-Einstellungen
export interface PortalSettings {
id: number;
portalEnabled: boolean;
portalEmail?: string;
portalLastLogin?: string;
hasPassword: boolean;
}
// Vertreter-Beziehung
export interface CustomerRepresentative {
id: number;
customerId: number;
customer?: CustomerSummary;
representativeId: number;
representative?: CustomerSummary;
notes?: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface Address {
id: number;
customerId: number;
type: 'DELIVERY_RESIDENCE' | 'BILLING';
street: string;
houseNumber: string;
postalCode: string;
city: string;
country: string;
isDefault: boolean;
}
export interface BankCard {
id: number;
customerId: number;
accountHolder: string;
iban: string;
bic?: string;
bankName?: string;
expiryDate?: string;
documentPath?: string;
isActive: boolean;
}
export interface IdentityDocument {
id: number;
customerId: number;
type: 'ID_CARD' | 'PASSPORT' | 'DRIVERS_LICENSE' | 'OTHER';
documentNumber: string;
issuingAuthority?: string;
issueDate?: string;
expiryDate?: string;
documentPath?: string;
isActive: boolean;
// Führerschein-spezifische Felder
licenseClasses?: string;
licenseIssueDate?: string;
}
export interface Meter {
id: number;
customerId: number;
meterNumber: string;
type: 'ELECTRICITY' | 'GAS';
location?: string;
isActive: boolean;
readings?: MeterReading[];
}
export interface MeterReading {
id: number;
meterId: number;
readingDate: string;
value: number;
unit: string;
notes?: string;
}
export type ContractTaskStatus = 'OPEN' | 'COMPLETED';
export interface ContractTaskSubtask {
id: number;
taskId: number;
title: string;
status: ContractTaskStatus;
createdBy?: string;
completedAt?: string;
createdAt: string;
updatedAt: string;
}
export interface ContractTaskContract {
id: number;
contractNumber: string;
customerId: number;
customer?: {
id: number;
firstName: string;
lastName: string;
companyName?: string;
customerNumber: string;
};
provider?: {
id: number;
name: string;
};
tariff?: {
id: number;
name: string;
};
providerName?: string;
tariffName?: string;
}
export interface ContractTask {
id: number;
contractId: number;
title: string;
description?: string;
status: ContractTaskStatus;
visibleInPortal: boolean;
createdBy?: string;
completedAt?: string;
subtasks?: ContractTaskSubtask[];
contract?: ContractTaskContract;
createdAt: string;
updatedAt: string;
}
export interface SalesPlatform {
id: number;
name: string;
contactInfo?: string;
isActive: boolean;
}
export interface CancellationPeriod {
id: number;
code: string;
description: string;
isActive: boolean;
}
export interface ContractDuration {
id: number;
code: string;
description: string;
isActive: boolean;
}
export interface ContractCategory {
id: number;
code: string;
name: string;
icon?: string;
color?: string;
sortOrder: number;
isActive: boolean;
_count?: {
contracts: number;
};
}
export interface Provider {
id: number;
name: string;
portalUrl?: string;
usernameFieldName?: string;
passwordFieldName?: string;
isActive: boolean;
tariffs?: Tariff[];
_count?: {
contracts: number;
tariffs: number;
};
}
export interface Tariff {
id: number;
providerId: number;
provider?: Provider;
name: string;
isActive: boolean;
_count?: {
contracts: number;
};
}
export type ContractType = 'ELECTRICITY' | 'GAS' | 'DSL' | 'CABLE' | 'FIBER' | 'MOBILE' | 'TV' | 'CAR_INSURANCE';
export type ContractStatus = 'DRAFT' | 'PENDING' | 'ACTIVE' | 'CANCELLED' | 'EXPIRED' | 'DEACTIVATED';
export interface Contract {
id: number;
contractNumber: string;
customerId: number;
customer?: Customer;
type: ContractType;
status: ContractStatus;
addressId?: number;
address?: Address;
bankCardId?: number;
bankCard?: BankCard;
identityDocumentId?: number;
identityDocument?: IdentityDocument;
salesPlatformId?: number;
salesPlatform?: SalesPlatform;
previousContractId?: number;
previousContract?: Contract;
providerId?: number;
provider?: Provider;
tariffId?: number;
tariff?: Tariff;
contractCategoryId?: number;
contractCategory?: ContractCategory;
stressfreiEmailId?: number;
stressfreiEmail?: StressfreiEmail;
providerName?: string;
tariffName?: string;
customerNumberAtProvider?: string;
priceFirst12Months?: string;
priceFrom13Months?: string;
priceAfter24Months?: string;
startDate?: string;
endDate?: string;
cancellationPeriodId?: number;
cancellationPeriod?: CancellationPeriod;
contractDurationId?: number;
contractDuration?: ContractDuration;
commission?: number;
portalUsername?: string;
portalPasswordEncrypted?: string;
notes?: string;
// Kündigungsdokumente
cancellationLetterPath?: string;
cancellationConfirmationPath?: string;
cancellationLetterOptionsPath?: string;
cancellationConfirmationOptionsPath?: string;
// Kündigungsdaten
cancellationConfirmationDate?: string;
cancellationConfirmationOptionsDate?: string;
wasSpecialCancellation?: boolean;
energyDetails?: EnergyContractDetails;
internetDetails?: InternetContractDetails;
mobileDetails?: MobileContractDetails;
tvDetails?: TvContractDetails;
carInsuranceDetails?: CarInsuranceDetails;
followUpContract?: {
id: number;
contractNumber: string;
status: ContractStatus;
};
createdAt: string;
updatedAt: string;
}
export interface EnergyContractDetails {
id: number;
contractId: number;
meterId?: number;
meter?: Meter;
annualConsumption?: number;
basePrice?: number;
unitPrice?: number;
bonus?: number;
previousProviderName?: string;
previousCustomerNumber?: string;
}
export interface InternetContractDetails {
id: number;
contractId: number;
downloadSpeed?: number;
uploadSpeed?: number;
routerModel?: string;
routerSerialNumber?: string;
installationDate?: string;
// Internet-Zugangsdaten
internetUsername?: string;
internetPasswordEncrypted?: string;
// Glasfaser-spezifisch
homeId?: string;
// Vodafone DSL/Kabel spezifisch
activationCode?: string;
phoneNumbers?: PhoneNumber[];
}
export interface PhoneNumber {
id: number;
phoneNumber: string;
isMain: boolean;
// SIP-Zugangsdaten
sipUsername?: string;
sipPasswordEncrypted?: string;
sipServer?: string;
}
export interface SimCard {
id: number;
mobileDetailsId: number;
phoneNumber?: string;
simCardNumber?: string;
pin?: string; // verschlüsselt
puk?: string; // verschlüsselt
isMultisim: boolean;
isMain: boolean;
createdAt?: string;
updatedAt?: string;
}
export interface MobileContractDetails {
id: number;
contractId: number;
requiresMultisim?: boolean;
dataVolume?: number;
includedMinutes?: number;
includedSMS?: number;
deviceModel?: string;
deviceImei?: string;
simCards?: SimCard[];
// Legacy-Felder
phoneNumber?: string;
simCardNumber?: string;
}
export interface TvContractDetails {
id: number;
contractId: number;
receiverModel?: string;
smartcardNumber?: string;
package?: string;
}
export interface CarInsuranceDetails {
id: number;
contractId: number;
licensePlate?: string;
hsn?: string;
tsn?: string;
vin?: string;
vehicleType?: string;
firstRegistration?: string;
noClaimsClass?: string;
insuranceType: 'LIABILITY' | 'PARTIAL' | 'FULL';
deductiblePartial?: number;
deductibleFull?: number;
policyNumber?: string;
previousInsurer?: string;
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
error?: string;
pagination?: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
// ==================== VERTRAGS-COCKPIT ====================
export type CockpitUrgencyLevel = 'critical' | 'warning' | 'ok' | 'none';
export interface CockpitIssue {
type: string;
label: string;
urgency: CockpitUrgencyLevel;
daysRemaining?: number;
details?: string;
}
export interface CockpitContract {
id: number;
contractNumber: string;
type: ContractType;
status: ContractStatus;
customer: {
id: number;
customerNumber: string;
name: string;
};
provider?: {
id: number;
name: string;
};
tariff?: {
id: number;
name: string;
};
providerName?: string;
tariffName?: string;
issues: CockpitIssue[];
highestUrgency: CockpitUrgencyLevel;
}
export interface CockpitSummary {
totalContracts: number;
criticalCount: number;
warningCount: number;
okCount: number;
byCategory: {
cancellationDeadlines: number;
contractEnding: number;
missingCredentials: number;
missingData: number;
openTasks: number;
pendingContracts: number;
};
}
export interface CockpitResult {
contracts: CockpitContract[];
summary: CockpitSummary;
thresholds: {
criticalDays: number;
warningDays: number;
okDays: number;
};
}