Initial commit - Stand 26.04.2026
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# Build stage
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
RUN npm install -g nodemon
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev", "--", "--host"]
|
||||
@@ -0,0 +1,13 @@
|
||||
import { build } from 'vite';
|
||||
try {
|
||||
const result = await build();
|
||||
console.log('BUILD OK');
|
||||
if (result?.output) {
|
||||
result.output.forEach(o => console.log(' →', o.fileName, o.type, o.code?.length || ''));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('BUILD ERROR:', e.message);
|
||||
if (e.loc) console.error('Location:', JSON.stringify(e.loc));
|
||||
if (e.frame) console.error(e.frame);
|
||||
console.error(e.stack);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SteuerFlow - Buchhaltung</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
location /uploads {
|
||||
proxy_pass http://backend:3000;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "buchhaltungs-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"lucide-react": "^0.294.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,346 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
LayoutDashboard, FileText, Clock, CreditCard, Home, TrendingUp, LogOut, Users,
|
||||
ClipboardCheck, Building2, Calculator, ChevronRight, ChevronDown, Settings,
|
||||
Briefcase, DollarSign, BarChart3
|
||||
} from 'lucide-react';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import Stunden from './components/Stunden';
|
||||
import Kredite from './components/Kredite';
|
||||
import PrivateKosten from './components/PrivateKosten';
|
||||
import Dokumente from './components/Dokumente';
|
||||
import Geschaeftsplanung from './components/Geschaeftsplanung';
|
||||
import Auftragsnachweis from './components/Auftragsnachweis';
|
||||
import Objekte from './components/nebenkosten/Objekte';
|
||||
import Mieter from './components/nebenkosten/Mieter';
|
||||
import Nebenkostenabrechnung from './components/nebenkosten/Nebenkostenabrechnung';
|
||||
import VermietungsBilanz from './components/nebenkosten/VermietungsBilanz';
|
||||
import { AuthProvider, useAuth, ProtectedRoute } from './contexts/AuthContext';
|
||||
import Login from './components/auth/Login';
|
||||
import SetupWizard from './components/auth/SetupWizard';
|
||||
import UserManagement from './components/auth/user-management/UserManagement';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
function AppContent() {
|
||||
const { isAuthenticated, loading, needsSetup, logout, user, isAdmin } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState('dashboard');
|
||||
const [apiStatus, setApiStatus] = useState('loading');
|
||||
const [expandedFolders, setExpandedFolders] = useState(['privat', 'geschaeftlich', 'vermietung', 'steuer', 'auftragsnachweis']);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
fetch(`${API_URL}/health`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
console.log('API OK:', data);
|
||||
setApiStatus('ok');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('API Error:', err);
|
||||
setApiStatus('error');
|
||||
});
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const toggleFolder = (folderId) => {
|
||||
setExpandedFolders(prev =>
|
||||
prev.includes(folderId)
|
||||
? prev.filter(id => id !== folderId)
|
||||
: [...prev, folderId]
|
||||
);
|
||||
};
|
||||
|
||||
// Dashboard separat ganz oben
|
||||
const dashboardItem = { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard };
|
||||
|
||||
const menuStructure = [
|
||||
{
|
||||
id: 'privat',
|
||||
label: 'Privat',
|
||||
icon: Home,
|
||||
items: [
|
||||
{ id: 'privat-kredite', label: 'Kredit-Übersicht', icon: CreditCard },
|
||||
{ id: 'privat-kosten', label: 'Private Kosten', icon: DollarSign },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'geschaeftlich',
|
||||
label: 'Geschäftlich',
|
||||
icon: Briefcase,
|
||||
items: [
|
||||
{ id: 'stunden', label: 'Stunden & Pauschalen', icon: Clock },
|
||||
{ id: 'auftragsnachweis', label: 'Auftragsnachweis', icon: ClipboardCheck },
|
||||
{ id: 'planung', label: 'Geschäftsplanung', icon: TrendingUp },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'vermietung',
|
||||
label: 'Vermietung',
|
||||
icon: Building2,
|
||||
items: [
|
||||
{ id: 'objekte', label: 'Objekte', icon: Home },
|
||||
{ id: 'mieter', label: 'Mieter', icon: Users },
|
||||
{ id: 'nk-abrechnung', label: 'NK-Abrechnung', icon: Calculator },
|
||||
{ id: 'vermietungs-bilanz', label: 'Vermietungs-Bilanz', icon: BarChart3 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'steuer',
|
||||
label: 'Steuer',
|
||||
icon: FileText,
|
||||
items: [
|
||||
{ id: 'docs', label: 'Dokumente', icon: FileText },
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
// Admin separat
|
||||
const adminItem = { id: 'users', label: 'Benutzerverwaltung', icon: Settings };
|
||||
|
||||
// Setup Wizard anzeigen wenn keine User existieren
|
||||
if (needsSetup) {
|
||||
return <SetupWizard />;
|
||||
}
|
||||
|
||||
// Login anzeigen wenn nicht eingeloggt
|
||||
if (!isAuthenticated && !loading) {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
if (apiStatus === 'loading' && isAuthenticated) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-xl">⏳ Lade SteuerFlow...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (apiStatus === 'error') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="bg-red-100 text-red-700 p-6 rounded-lg">
|
||||
<h2 className="font-bold text-lg mb-2">❌ Backend nicht erreichbar</h2>
|
||||
<p>Bitte überprüfe ob Docker läuft.</p>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex flex-col">
|
||||
{/* Header oben */}
|
||||
<header className="bg-white shadow-md h-20 flex-shrink-0">
|
||||
<div className="px-6 h-full flex items-center justify-between">
|
||||
{/* Links: Logo + Titel */}
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Finanz Flow Logo"
|
||||
className="h-12 w-auto"
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Finanz Flow</h1>
|
||||
<span className="text-sm text-blue-600">by Taeger IT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rechts: Status + User Info */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-green-600 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
|
||||
✅ Online
|
||||
</span>
|
||||
<span className="text-sm text-gray-700">{user?.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content-Bereich mit Sidebar */}
|
||||
<div className="flex flex-1">
|
||||
{/* Sidebar links */}
|
||||
<aside className="w-72 bg-white shadow-md flex-shrink-0 flex flex-col">
|
||||
<div className="pl-4 pr-4 py-4 flex-1">
|
||||
{/* Navigation */}
|
||||
<nav className="space-y-1">
|
||||
{/* Dashboard - ganz oben, separat */}
|
||||
<button
|
||||
onClick={() => setActiveTab(dashboardItem.id)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
activeTab === dashboardItem.id
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<dashboardItem.icon className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="font-medium">{dashboardItem.label}</span>
|
||||
</button>
|
||||
|
||||
{/* Ordnerstruktur */}
|
||||
{menuStructure.map(folder => {
|
||||
const FolderIcon = folder.icon;
|
||||
const isExpanded = expandedFolders.includes(folder.id);
|
||||
const hasActiveItem = folder.items.some(item => item.id === activeTab);
|
||||
|
||||
return (
|
||||
<div key={folder.id} className="mt-2">
|
||||
{/* Ordner Header */}
|
||||
<button
|
||||
onClick={() => toggleFolder(folder.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors ${
|
||||
hasActiveItem
|
||||
? 'text-blue-600 bg-blue-50'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 flex-shrink-0" />
|
||||
)}
|
||||
<FolderIcon className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="font-medium text-left flex-1">{folder.label}</span>
|
||||
</button>
|
||||
|
||||
{/* Unterpunkte */}
|
||||
{isExpanded && (
|
||||
<div className="mt-1 ml-6 pl-4 border-l-2 border-gray-200">
|
||||
{folder.items.map(item => {
|
||||
const ItemIcon = item.icon;
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const isChildrenExpanded = expandedFolders.includes(item.id);
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (hasChildren) {
|
||||
toggleFolder(item.id);
|
||||
} else {
|
||||
setActiveTab(item.id);
|
||||
}
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
activeTab === item.id || item.children?.some(c => c.id === activeTab)
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{hasChildren && (
|
||||
isChildrenExpanded ? (
|
||||
<ChevronDown className="w-3 h-3 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 flex-shrink-0" />
|
||||
)
|
||||
)}
|
||||
<ItemIcon className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
|
||||
{/* Sub-children */}
|
||||
{hasChildren && isChildrenExpanded && (
|
||||
<div className="ml-6 pl-2 border-l border-gray-200 mt-1">
|
||||
{item.children.map(child => {
|
||||
const ChildIcon = child.icon;
|
||||
return (
|
||||
<button
|
||||
key={child.id}
|
||||
onClick={() => setActiveTab(child.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-1.5 rounded-lg transition-colors text-sm ${
|
||||
activeTab === child.id
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<ChildIcon className="w-3 h-3 flex-shrink-0" />
|
||||
<span>{child.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Admin-Bereich - nur für Admins */}
|
||||
{isAdmin && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={() => setActiveTab(adminItem.id)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
activeTab === adminItem.id
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<adminItem.icon className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="font-medium">{adminItem.label}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* User Info & Logout - unten in Sidebar */}
|
||||
<div className="p-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 p-6 overflow-y-auto">
|
||||
<ProtectedRoute>
|
||||
{activeTab === 'dashboard' && <Dashboard />}
|
||||
{activeTab === 'stunden' && <Stunden />}
|
||||
{activeTab === 'auftragsnachweis' && <Auftragsnachweis />}
|
||||
{activeTab === 'privat-kredite' && <Kredite />}
|
||||
{activeTab === 'privat-kosten' && <PrivateKosten />}
|
||||
|
||||
{activeTab === 'docs' && <Dokumente API_URL={API_URL} />}
|
||||
{activeTab === 'planung' && <Geschaeftsplanung />}
|
||||
{activeTab === 'objekte' && <Objekte />}
|
||||
{activeTab === 'mieter' && <Mieter />}
|
||||
{activeTab === 'nk-abrechnung' && <Nebenkostenabrechnung />}
|
||||
{activeTab === 'vermietungs-bilanz' && <VermietungsBilanz />}
|
||||
{activeTab === 'users' && isAdmin && <UserManagement />}
|
||||
{!['dashboard', 'stunden', 'auftragsnachweis', 'kredite', 'docs', 'planung', 'objekte', 'mieter', 'nk-abrechnung', 'users', 'privat-kredite', 'privat-kosten', 'vermietungs-bilanz'].includes(activeTab) && (
|
||||
<div className="bg-white rounded-lg shadow p-8">
|
||||
<h2 className="text-xl font-bold mb-4">
|
||||
⏳ Lade...
|
||||
</h2>
|
||||
<p className="text-gray-600">Diese Komponente wird noch geladen...</p>
|
||||
</div>
|
||||
)}
|
||||
</ProtectedRoute>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// API Service für Nebenkosten-Module
|
||||
const API_URL = '/api';
|
||||
|
||||
// Objekte API
|
||||
export const objekteAPI = {
|
||||
async getAll() {
|
||||
const res = await fetch(`${API_URL}/objekte`);
|
||||
if (!res.ok) throw new Error('Fehler beim Laden der Objekte');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
const res = await fetch(`${API_URL}/objekte/${id}`);
|
||||
if (!res.ok) throw new Error('Objekt nicht gefunden');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async create(data) {
|
||||
const res = await fetch(`${API_URL}/objekte`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Erstellen');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async update(id, data) {
|
||||
const res = await fetch(`${API_URL}/objekte/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Aktualisieren');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
const res = await fetch(`${API_URL}/objekte/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Löschen');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async getKosten(objektId, jahr) {
|
||||
const res = await fetch(`${API_URL}/objekte/${objektId}/kosten?jahr=${jahr}`);
|
||||
if (!res.ok) throw new Error('Fehler beim Laden der Kosten');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async addKosten(objektId, data) {
|
||||
const res = await fetch(`${API_URL}/objekte/${objektId}/kosten`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Hinzufügen der Kosten');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async deleteKosten(objektId, kostenId) {
|
||||
const res = await fetch(`${API_URL}/objekte/${objektId}/kosten/${kostenId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Löschen der Kosten');
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
|
||||
// Mieter API
|
||||
export const mieterAPI = {
|
||||
async getAll() {
|
||||
const res = await fetch(`${API_URL}/mieter`);
|
||||
if (!res.ok) throw new Error('Fehler beim Laden der Mieter');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
const res = await fetch(`${API_URL}/mieter/${id}`);
|
||||
if (!res.ok) throw new Error('Mieter nicht gefunden');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async create(data) {
|
||||
const res = await fetch(`${API_URL}/mieter`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Erstellen');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async createWithContract(data) {
|
||||
const res = await fetch(`${API_URL}/mieter-mit-vertrag`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Erstellen');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async update(id, data) {
|
||||
const res = await fetch(`${API_URL}/mieter/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Aktualisieren');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async updateWithContract(id, data) {
|
||||
const res = await fetch(`${API_URL}/mieter/${id}/mit-vertrag`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Aktualisieren');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
const res = await fetch(`${API_URL}/mieter/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Löschen');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async getMietvertraege(mieterId) {
|
||||
const res = await fetch(`${API_URL}/mieter/${mieterId}/mietvertraege`);
|
||||
if (!res.ok) throw new Error('Fehler beim Laden der Mietverträge');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async getVorauszahlungen(mieterId, jahr) {
|
||||
const res = await fetch(`${API_URL}/mieter/${mieterId}/vorauszahlungen?jahr=${jahr}`);
|
||||
if (!res.ok) throw new Error('Fehler beim Laden der Vorauszahlungen');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async addVorauszahlung(mieterId, data) {
|
||||
const res = await fetch(`${API_URL}/mieter/${mieterId}/vorauszahlungen`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Hinzufügen');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async deleteVorauszahlung(mieterId, vorauszahlungId) {
|
||||
const res = await fetch(`${API_URL}/mieter/${mieterId}/vorauszahlungen/${vorauszahlungId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Löschen');
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
|
||||
// Nebenkostenabrechnung API
|
||||
export const nebenkostenabrechnungAPI = {
|
||||
async getAll() {
|
||||
const res = await fetch(`${API_URL}/nebenkostenabrechnungen`);
|
||||
if (!res.ok) throw new Error('Fehler beim Laden der Abrechnungen');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
const res = await fetch(`${API_URL}/nebenkostenabrechnungen/${id}`);
|
||||
if (!res.ok) throw new Error('Abrechnung nicht gefunden');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async vorschau(data) {
|
||||
const res = await fetch(`${API_URL}/nebenkostenabrechnung/vorschau`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler bei der Vorschau');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async create(data) {
|
||||
const res = await fetch(`${API_URL}/nebenkostenabrechnung`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Erstellen');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
const res = await fetch(`${API_URL}/nebenkostenabrechnungen/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Löschen');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async downloadPDF(id, entwurf = false) {
|
||||
const url = `${API_URL}/nebenkostenabrechnung/${id}/pdf${entwurf ? '?entwurf=true' : ''}`;
|
||||
const res = await fetch(url, { method: 'POST' });
|
||||
if (!res.ok) throw new Error('Fehler beim PDF-Download');
|
||||
return res.blob();
|
||||
},
|
||||
};
|
||||
|
||||
// Vermietungsbilanz API
|
||||
export const vermietungsbilanzAPI = {
|
||||
async getBilanz(jahr) {
|
||||
const res = await fetch(`${API_URL}/vermietungsbilanz?jahr=${jahr}`);
|
||||
if (!res.ok) throw new Error('Fehler beim Laden der Bilanz');
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,314 @@
|
||||
// API Service für alle Backend-Calls
|
||||
// Docker: Frontend (nginx:80) -> Backend (3001) via nginx proxy
|
||||
const API_URL = '/api';
|
||||
|
||||
// Helper für API Calls
|
||||
async function apiCall(endpoint, options = {}) {
|
||||
const url = `${API_URL}${endpoint}`;
|
||||
const config = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
};
|
||||
|
||||
if (options.body && typeof options.body === 'object') {
|
||||
config.body = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(error.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`API Error (${endpoint}):`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Kredite API
|
||||
export const krediteAPI = {
|
||||
getAll: (params = {}) => {
|
||||
const queryParams = new URLSearchParams(params).toString();
|
||||
return apiCall(`/kredite${queryParams ? '?' + queryParams : ''}`);
|
||||
},
|
||||
|
||||
create: (data) => apiCall('/kredite', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
update: (id, data) => apiCall(`/kredite/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
// PATCH für partielle Updates (z.B. nur Restschuld)
|
||||
patch: (id, data) => apiCall(`/kredite/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
delete: (id) => apiCall(`/kredite/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
|
||||
getBuchungen: (id) => apiCall(`/kredite/${id}/buchungen`),
|
||||
|
||||
addBuchung: (id, data) => apiCall(`/kredite/${id}/buchungen`, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
deleteBuchung: (id, buchungId) => apiCall(`/kredite/${id}/buchungen/${buchungId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
|
||||
// Zahlungen API (Alias für Buchungen)
|
||||
getZahlungen: (id) => apiCall(`/kredite/${id}/zahlungen`),
|
||||
|
||||
addZahlung: (id, data) => apiCall(`/kredite/${id}/zahlungen`, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
deleteZahlung: (id, zahlungId) => apiCall(`/kredite/${id}/zahlungen/${zahlungId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
};
|
||||
|
||||
// Stunden API
|
||||
export const stundenAPI = {
|
||||
getAll: (params = {}) => {
|
||||
const queryParams = new URLSearchParams(params).toString();
|
||||
return apiCall(`/stunden${queryParams ? '?' + queryParams : ''}`);
|
||||
},
|
||||
|
||||
create: (data) => apiCall('/stunden', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
update: (id, data) => apiCall(`/stunden/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
delete: (id) => apiCall(`/stunden/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
};
|
||||
|
||||
// Nebenkosten API
|
||||
export const nebenkostenAPI = {
|
||||
getAll: (params = {}) => {
|
||||
const queryParams = new URLSearchParams(params).toString();
|
||||
return apiCall(`/nebenkosten${queryParams ? '?' + queryParams : ''}`);
|
||||
},
|
||||
|
||||
create: (data) => apiCall('/nebenkosten', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
update: (id, data) => apiCall(`/nebenkosten/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
delete: (id) => apiCall(`/nebenkosten/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
};
|
||||
|
||||
// Kostenplanung API
|
||||
export const kostenplanungAPI = {
|
||||
getAll: (params = {}) => {
|
||||
const queryParams = new URLSearchParams(params).toString();
|
||||
return apiCall(`/kostenplanung${queryParams ? '?' + queryParams : ''}`);
|
||||
},
|
||||
|
||||
create: (data) => apiCall('/kostenplanung', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
update: (id, data) => apiCall(`/kostenplanung/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
delete: (id) => apiCall(`/kostenplanung/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
};
|
||||
|
||||
// Geschäftsplanung API (neu)
|
||||
export const geschaeftsplanungAPI = {
|
||||
// Monatliche Übersicht mit Ergebnis und Cashflow
|
||||
getMonatlich: (jahr) => apiCall(`/geschaeftsplanung/monatlich?jahr=${jahr}`),
|
||||
|
||||
// Kategorie-Übersicht
|
||||
getKategorien: (jahr) => apiCall(`/geschaeftsplanung/kategorien?jahr=${jahr}`),
|
||||
|
||||
// Miete vs Nebenkosten pro Objekt
|
||||
getObjektVergleich: (objekt, jahr) => apiCall(`/geschaeftsplanung/objektvergleich/${objekt}?jahr=${jahr}`),
|
||||
|
||||
// Umsatzvorschau (nur Einnahmen)
|
||||
getUmsatzVorschau: (jahr) => apiCall(`/geschaeftsplanung/umsatzvorschau?jahr=${jahr}`),
|
||||
};
|
||||
|
||||
// Kunden API (für Auftragsnachweise)
|
||||
export const kundenAPI = {
|
||||
getAll: () => apiCall('/kunden'),
|
||||
|
||||
create: (data) => apiCall('/kunden', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
update: (id, data) => apiCall(`/kunden/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
delete: (id) => apiCall(`/kunden/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
};
|
||||
|
||||
// Auftragsnachweise API
|
||||
export const auftragsnachweisAPI = {
|
||||
getAll: (params = {}) => {
|
||||
const queryParams = new URLSearchParams(params).toString();
|
||||
return apiCall(`/auftragsnachweise${queryParams ? '?' + queryParams : ''}`);
|
||||
},
|
||||
|
||||
getById: (id) => apiCall(`/auftragsnachweise/${id}`),
|
||||
|
||||
create: (data) => {
|
||||
// Stelle sicher, dass art_der_arbeit ein Array ist
|
||||
const sanitizedData = {
|
||||
...data,
|
||||
art_der_arbeit: Array.isArray(data.art_der_arbeit)
|
||||
? data.art_der_arbeit
|
||||
: (data.art_der_arbeit ? [data.art_der_arbeit] : []),
|
||||
stunden_ids: Array.isArray(data.stunden_ids)
|
||||
? data.stunden_ids
|
||||
: (data.stunden_ids ? [data.stunden_ids] : []),
|
||||
};
|
||||
return apiCall('/auftragsnachweise', {
|
||||
method: 'POST',
|
||||
body: sanitizedData,
|
||||
});
|
||||
},
|
||||
|
||||
delete: (id) => apiCall(`/auftragsnachweise/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
|
||||
// PDF generieren und herunterladen
|
||||
generatePDF: async (id, firmenname) => {
|
||||
const response = await fetch(`${API_URL}/auftragsnachweise/${id}/pdf`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ firmenname }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Bei Fehler erst als Text versuchen, dann als JSON
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
let errorMessage = errorText;
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText);
|
||||
errorMessage = errorJson.error || errorText;
|
||||
} catch (e) {
|
||||
// Nicht JSON, Text verwenden
|
||||
}
|
||||
throw new Error(errorMessage || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
// Blob für Download
|
||||
const blob = await response.blob();
|
||||
if (blob.size === 0) {
|
||||
throw new Error('PDF ist leer');
|
||||
}
|
||||
|
||||
// Download-Dateiname aus Header extrahieren oder Default verwenden
|
||||
const disposition = response.headers.get('Content-Disposition');
|
||||
let filename = `auftragsnachweis-${id}.pdf`;
|
||||
if (disposition && disposition.includes('filename=')) {
|
||||
const match = disposition.match(/filename="([^"]+)"/);
|
||||
if (match) filename = match[1];
|
||||
}
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
// Vorhandenes PDF herunterladen
|
||||
downloadPDF: async (id) => {
|
||||
const response = await fetch(`${API_URL}/auftragsnachweise/${id}/pdf`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
let errorMessage = errorText;
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText);
|
||||
errorMessage = errorJson.error || errorText;
|
||||
} catch (e) {
|
||||
// Nicht JSON, Text verwenden
|
||||
}
|
||||
throw new Error(errorMessage || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
if (blob.size === 0) {
|
||||
throw new Error('PDF ist leer');
|
||||
}
|
||||
|
||||
const disposition = response.headers.get('Content-Disposition');
|
||||
let filename = `auftragsnachweis-${id}.pdf`;
|
||||
if (disposition && disposition.includes('filename=')) {
|
||||
const match = disposition.match(/filename="([^"]+)"/);
|
||||
if (match) filename = match[1];
|
||||
}
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
// Health Check
|
||||
export const healthCheck = () => apiCall('/health');
|
||||
|
||||
export default {
|
||||
kredite: krediteAPI,
|
||||
stunden: stundenAPI,
|
||||
nebenkosten: nebenkostenAPI,
|
||||
kostenplanung: kostenplanungAPI,
|
||||
geschaeftsplanung: geschaeftsplanungAPI,
|
||||
health: healthCheck,
|
||||
};
|
||||
@@ -0,0 +1,778 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FileText, Plus, Trash2, Download, UserPlus, CheckSquare, X, AlertCircle, Clock, Users, FileCheck, Filter, RotateCcw, Calendar } from 'lucide-react';
|
||||
import { auftragsnachweisAPI, kundenAPI, stundenAPI } from '../api';
|
||||
|
||||
// Art der Arbeit Optionen
|
||||
const ARBEIT_ARTEN = [
|
||||
'Wartung',
|
||||
'Regiebericht',
|
||||
'Abnahme',
|
||||
'Lieferschein',
|
||||
'Installation',
|
||||
'Instandsetzung',
|
||||
'Systempflege',
|
||||
'Schulung',
|
||||
];
|
||||
|
||||
export default function Auftragsnachweis() {
|
||||
const [kunden, setKunden] = useState([]);
|
||||
const [stunden, setStunden] = useState([]);
|
||||
const [auftragsnachweise, setAuftragsnachweise] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Form States
|
||||
const [showKundenForm, setShowKundenForm] = useState(false);
|
||||
const [showAuftragsnachweisForm, setShowAuftragsnachweisForm] = useState(false);
|
||||
const [selectedStunden, setSelectedStunden] = useState([]);
|
||||
|
||||
// Filter States
|
||||
const [filterKunde, setFilterKunde] = useState('');
|
||||
const [filterDatumVon, setFilterDatumVon] = useState('');
|
||||
const [filterDatumBis, setFilterDatumBis] = useState('');
|
||||
const [filterArbeitArten, setFilterArbeitArten] = useState([]);
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
|
||||
// Kunden Form
|
||||
const [kundeForm, setKundeForm] = useState({
|
||||
name: '',
|
||||
strasse: '',
|
||||
plz: '',
|
||||
ort: '',
|
||||
telefon: '',
|
||||
email: '',
|
||||
});
|
||||
|
||||
// Auftragsnachweis Form
|
||||
const [auftragsnachweisForm, setAuftragsnachweisForm] = useState({
|
||||
kunde_id: '',
|
||||
datum: new Date().toISOString().split('T')[0],
|
||||
art_der_arbeit: [],
|
||||
software_version: '',
|
||||
durchgefuehrte_arbeiten: '',
|
||||
geliefertes_material: '',
|
||||
anlage_betriebsbereit: 'voll',
|
||||
anfahrt_km: '',
|
||||
abnahmebestaetigung_text: 'Hiermit bestätige ich, dass die Arbeiten durch Täger IT & Gebäude-Systeme ordnungsgemäß durchgeführt wurden.',
|
||||
});
|
||||
|
||||
// Daten laden
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [kundenData, stundenData, auftraegeData] = await Promise.all([
|
||||
kundenAPI.getAll(),
|
||||
stundenAPI.getAll({ status: 'offen' }),
|
||||
auftragsnachweisAPI.getAll(),
|
||||
]);
|
||||
setKunden(kundenData);
|
||||
setStunden(stundenData);
|
||||
setAuftragsnachweise(auftraegeData);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden: ' + err.message);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Kunden CRUD
|
||||
const addKunde = async () => {
|
||||
if (!kundeForm.name) return;
|
||||
|
||||
try {
|
||||
await kundenAPI.create(kundeForm);
|
||||
await loadData();
|
||||
setKundeForm({
|
||||
name: '',
|
||||
strasse: '',
|
||||
plz: '',
|
||||
ort: '',
|
||||
telefon: '',
|
||||
email: '',
|
||||
});
|
||||
setShowKundenForm(false);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Speichern: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteKunde = async (id) => {
|
||||
if (confirm('Kunde wirklich löschen?')) {
|
||||
try {
|
||||
await kundenAPI.delete(id);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Löschen: ' + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Stunden Auswahl
|
||||
const toggleStunde = (id) => {
|
||||
setSelectedStunden(prev =>
|
||||
prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
// Auftragsnachweis erstellen
|
||||
const createAuftragsnachweis = async () => {
|
||||
if (!auftragsnachweisForm.kunde_id) {
|
||||
setError('Bitte einen Kunden auswählen');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedStunden.length === 0) {
|
||||
setError('Bitte mindestens eine Stunde auswählen');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = {
|
||||
...auftragsnachweisForm,
|
||||
stunden_ids: selectedStunden,
|
||||
};
|
||||
|
||||
const result = await auftragsnachweisAPI.create(data);
|
||||
|
||||
// PDF generieren und herunterladen
|
||||
await auftragsnachweisAPI.generatePDF(result.id, 'Täger IT & Gebäude-Systeme');
|
||||
|
||||
await loadData();
|
||||
setShowAuftragsnachweisForm(false);
|
||||
setSelectedStunden([]);
|
||||
setAuftragsnachweisForm({
|
||||
kunde_id: '',
|
||||
datum: new Date().toISOString().split('T')[0],
|
||||
art_der_arbeit: [],
|
||||
software_version: '',
|
||||
durchgefuehrte_arbeiten: '',
|
||||
geliefertes_material: '',
|
||||
anlage_betriebsbereit: 'voll',
|
||||
anfahrt_km: '',
|
||||
abnahmebestaetigung_text: 'Hiermit bestätige ich, dass die Arbeiten durch Täger IT & Gebäude-Systeme ordnungsgemäß durchgeführt wurden.',
|
||||
});
|
||||
} catch (err) {
|
||||
setError('Fehler beim Erstellen: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAuftragsnachweis = async (id) => {
|
||||
if (confirm('Auftragsnachweis wirklich löschen?')) {
|
||||
try {
|
||||
await auftragsnachweisAPI.delete(id);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Löschen: ' + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const downloadPDF = async (id) => {
|
||||
try {
|
||||
await auftragsnachweisAPI.downloadPDF(id);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Download: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Berechne Summe der ausgewählten Stunden
|
||||
const selectedStundenDetails = stunden.filter(s => selectedStunden.includes(s.id));
|
||||
const totalStunden = selectedStundenDetails.reduce((sum, s) => sum + parseFloat(s.stunden), 0);
|
||||
const totalBetrag = selectedStundenDetails.reduce((sum, s) => sum + parseFloat(s.betrag), 0);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Lade...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-2 text-red-700">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-auto">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kundenverwaltung */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="text-blue-600" size={24} />
|
||||
<h2 className="text-xl font-bold">Adressbuch</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowKundenForm(!showKundenForm)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<UserPlus size={18} />
|
||||
{showKundenForm ? 'Abbrechen' : 'Neuer Kunde'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showKundenForm && (
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={kundeForm.name}
|
||||
onChange={(e) => setKundeForm({ ...kundeForm, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="Firmenname / Kunde"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Straße</label>
|
||||
<input
|
||||
type="text"
|
||||
value={kundeForm.strasse}
|
||||
onChange={(e) => setKundeForm({ ...kundeForm, strasse: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="Musterstraße 123"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">PLZ</label>
|
||||
<input
|
||||
type="text"
|
||||
value={kundeForm.plz}
|
||||
onChange={(e) => setKundeForm({ ...kundeForm, plz: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="25554"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ort</label>
|
||||
<input
|
||||
type="text"
|
||||
value={kundeForm.ort}
|
||||
onChange={(e) => setKundeForm({ ...kundeForm, ort: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="Wilster"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Telefon</label>
|
||||
<input
|
||||
type="text"
|
||||
value={kundeForm.telefon}
|
||||
onChange={(e) => setKundeForm({ ...kundeForm, telefon: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="+49..."
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-3">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">E-Mail</label>
|
||||
<input
|
||||
type="email"
|
||||
value={kundeForm.email}
|
||||
onChange={(e) => setKundeForm({ ...kundeForm, email: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="info@kunde.de"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={addKunde}
|
||||
className="mt-4 w-full md:w-auto px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||||
>
|
||||
💾 Kunde speichern
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kundenliste */}
|
||||
{kunden.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left py-2 px-4">Name</th>
|
||||
<th className="text-left py-2 px-4">Adresse</th>
|
||||
<th className="text-left py-2 px-4">Kontakt</th>
|
||||
<th className="py-2 px-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{kunden.map(kunde => (
|
||||
<tr key={kunde.id} className="border-b border-gray-100">
|
||||
<td className="py-2 px-4 font-medium">{kunde.name}</td>
|
||||
<td className="py-2 px-4 text-gray-600">
|
||||
{kunde.strasse && <div>{kunde.strasse}</div>}
|
||||
{(kunde.plz || kunde.ort) && (
|
||||
<div>{kunde.plz} {kunde.ort}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-4 text-gray-600">
|
||||
{kunde.telefon && <div>📞 {kunde.telefon}</div>}
|
||||
{kunde.email && <div>✉️ {kunde.email}</div>}
|
||||
</td>
|
||||
<td className="py-2 px-4">
|
||||
<button
|
||||
onClick={() => deleteKunde(kunde.id)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-4">Noch keine Kunden angelegt.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auftragsnachweis erstellen */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCheck className="text-green-600" size={24} />
|
||||
<h2 className="text-xl font-bold">Auftragsnachweis erstellen</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAuftragsnachweisForm(!showAuftragsnachweisForm)}
|
||||
disabled={kunden.length === 0 || stunden.length === 0}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-300 disabled:cursor-not-allowed"
|
||||
>
|
||||
<FileText size={18} />
|
||||
{showAuftragsnachweisForm ? 'Abbrechen' : 'Neuen Auftragsnachweis'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(kunden.length === 0 || stunden.length === 0) && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-yellow-700 mb-4">
|
||||
<AlertCircle size={18} className="inline mr-2" />
|
||||
{kunden.length === 0 && 'Bitte zuerst einen Kunden anlegen. '}
|
||||
{stunden.length === 0 && 'Bitte zuerst Stunden erfassen.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAuftragsnachweisForm && (
|
||||
<div className="space-y-6">
|
||||
{/* Stunden Auswahl */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="font-semibold mb-3 flex items-center gap-2">
|
||||
<Clock size={18} />
|
||||
Stunden auswählen
|
||||
</h3>
|
||||
|
||||
{stunden.length > 0 ? (
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{stunden.sort((a, b) => new Date(b.datum) - new Date(a.datum)).map(s => (
|
||||
<label key={s.id} className="flex items-center gap-3 p-2 bg-white rounded border hover:bg-blue-50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedStunden.includes(s.id)}
|
||||
onChange={() => toggleStunde(s.id)}
|
||||
className="w-5 h-5 text-blue-600"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{s.kunde}</div>
|
||||
<div className="text-sm text-gray-600">{s.beschreibung}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-semibold">{new Date(s.datum).toLocaleDateString('de-DE')}</div>
|
||||
<div className="text-sm">{s.stunden}h × {s.stundensatz}€ = {parseFloat(s.betrag).toFixed(2)}€</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500">Keine offenen Stunden vorhanden.</p>
|
||||
)}
|
||||
|
||||
{selectedStunden.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-blue-100 rounded-lg text-blue-800">
|
||||
<strong>Ausgewählt:</strong> {selectedStunden.length} Einträge,
|
||||
{' '}{totalStunden.toFixed(1)} Stunden,
|
||||
{' '}{totalBetrag.toFixed(2)}€
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auftragsnachweis Formular */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 space-y-4">
|
||||
<h3 className="font-semibold">Auftragsdetails</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kunde *</label>
|
||||
<select
|
||||
value={auftragsnachweisForm.kunde_id}
|
||||
onChange={(e) => setAuftragsnachweisForm({ ...auftragsnachweisForm, kunde_id: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
>
|
||||
<option value="">Bitte wählen...</option>
|
||||
{kunden.map(k => (
|
||||
<option key={k.id} value={k.id}>{k.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Datum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={auftragsnachweisForm.datum}
|
||||
onChange={(e) => setAuftragsnachweisForm({ ...auftragsnachweisForm, datum: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Art der Arbeit */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Art der Arbeit</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ARBEIT_ARTEN.map(art => (
|
||||
<label key={art} className="flex items-center gap-1 px-3 py-1 bg-white border rounded-full cursor-pointer hover:bg-blue-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auftragsnachweisForm.art_der_arbeit.includes(art)}
|
||||
onChange={(e) => {
|
||||
const newArbeit = e.target.checked
|
||||
? [...auftragsnachweisForm.art_der_arbeit, art]
|
||||
: auftragsnachweisForm.art_der_arbeit.filter(a => a !== art);
|
||||
setAuftragsnachweisForm({ ...auftragsnachweisForm, art_der_arbeit: newArbeit });
|
||||
}}
|
||||
className="mr-1"
|
||||
/>
|
||||
{art}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Software-Version</label>
|
||||
<input
|
||||
type="text"
|
||||
value={auftragsnachweisForm.software_version}
|
||||
onChange={(e) => setAuftragsnachweisForm({ ...auftragsnachweisForm, software_version: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="z.B. AEOS 4.2.1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Durchgeführte Arbeiten</label>
|
||||
<textarea
|
||||
value={auftragsnachweisForm.durchgefuehrte_arbeiten}
|
||||
onChange={(e) => setAuftragsnachweisForm({ ...auftragsnachweisForm, durchgefuehrte_arbeiten: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 h-24"
|
||||
placeholder="Beschreibung der durchgeführten Arbeiten..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Geliefertes Material</label>
|
||||
<textarea
|
||||
value={auftragsnachweisForm.geliefertes_material}
|
||||
onChange={(e) => setAuftragsnachweisForm({ ...auftragsnachweisForm, geliefertes_material: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 h-20"
|
||||
placeholder="z.B. 2x Kamera XYZ, 1x Switch..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Anlage betriebsbereit</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
value="voll"
|
||||
checked={auftragsnachweisForm.anlage_betriebsbereit === 'voll'}
|
||||
onChange={(e) => setAuftragsnachweisForm({ ...auftragsnachweisForm, anlage_betriebsbereit: e.target.value })}
|
||||
/>
|
||||
Vollständig
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
value="eingeschrankt"
|
||||
checked={auftragsnachweisForm.anlage_betriebsbereit === 'eingeschrankt'}
|
||||
onChange={(e) => setAuftragsnachweisForm({ ...auftragsnachweisForm, anlage_betriebsbereit: e.target.value })}
|
||||
/>
|
||||
Eingeschränkt
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Anfahrt (km)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={auftragsnachweisForm.anfahrt_km}
|
||||
onChange={(e) => setAuftragsnachweisForm({ ...auftragsnachweisForm, anfahrt_km: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="z.B. 45"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Abnahmebestätigung Text</label>
|
||||
<textarea
|
||||
value={auftragsnachweisForm.abnahmebestaetigung_text}
|
||||
onChange={(e) => setAuftragsnachweisForm({ ...auftragsnachweisForm, abnahmebestaetigung_text: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 h-20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={createAuftragsnachweis}
|
||||
className="w-full py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 font-semibold flex items-center justify-center gap-2"
|
||||
>
|
||||
<FileCheck size={20} />
|
||||
Auftragsnachweis erstellen & PDF downloaden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bestehende Auftragsnachweise */}
|
||||
{auftragsnachweise.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||
<FileText className="text-blue-600" size={24} />
|
||||
Erstellte Auftragsnachweise
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowFilter(!showFilter)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
|
||||
showFilter || filterKunde || filterDatumVon || filterDatumBis || filterArbeitArten.length > 0
|
||||
? 'bg-blue-100 text-blue-700 hover:bg-blue-200'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Filter size={18} />
|
||||
Filter
|
||||
{(filterKunde || filterDatumVon || filterDatumBis || filterArbeitArten.length > 0) && (
|
||||
<span className="bg-blue-600 text-white text-xs px-2 py-0.5 rounded-full">
|
||||
Aktiv
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter-Leiste */}
|
||||
{showFilter && (
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-700 flex items-center gap-2">
|
||||
<Filter size={18} />
|
||||
Filter Optionen
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilterKunde('');
|
||||
setFilterDatumVon('');
|
||||
setFilterDatumBis('');
|
||||
setFilterArbeitArten([]);
|
||||
}}
|
||||
className="flex items-center gap-1 text-sm text-red-600 hover:text-red-800"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
Filter zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Kunde Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kunde</label>
|
||||
<select
|
||||
value={filterKunde}
|
||||
onChange={(e) => setFilterKunde(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Alle Kunden</option>
|
||||
{kunden.map(k => (
|
||||
<option key={k.id} value={k.id}>{k.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Datum von */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-1">
|
||||
<Calendar size={14} />
|
||||
Datum von
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filterDatumVon}
|
||||
onChange={(e) => setFilterDatumVon(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Datum bis */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-1">
|
||||
<Calendar size={14} />
|
||||
Datum bis
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filterDatumBis}
|
||||
onChange={(e) => setFilterDatumBis(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Anzahl Ergebnisse */}
|
||||
<div className="flex items-end">
|
||||
<div className="text-sm text-gray-600">
|
||||
Gefiltert: <span className="font-semibold text-blue-600">
|
||||
{auftragsnachweise.filter(an => {
|
||||
// Kunde Filter
|
||||
if (filterKunde && an.kunde_id !== parseInt(filterKunde) && an.kunde_name !== kunden.find(k => k.id === parseInt(filterKunde))?.name) return false;
|
||||
|
||||
// Datum Filter
|
||||
const anDatum = new Date(an.datum);
|
||||
if (filterDatumVon && anDatum < new Date(filterDatumVon)) return false;
|
||||
if (filterDatumBis) {
|
||||
const bisDatum = new Date(filterDatumBis);
|
||||
bisDatum.setHours(23, 59, 59);
|
||||
if (anDatum > bisDatum) return false;
|
||||
}
|
||||
|
||||
// Art der Arbeit Filter
|
||||
if (filterArbeitArten.length > 0) {
|
||||
const anArbeiten = Array.isArray(an.art_der_arbeit) ? an.art_der_arbeit : (an.art_der_arbeit ? [an.art_der_arbeit] : []);
|
||||
if (!filterArbeitArten.some(art => anArbeiten.includes(art))) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}).length}
|
||||
</span> von {auftragsnachweise.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Art der Arbeit Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Art der Arbeit</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ARBEIT_ARTEN.map(art => (
|
||||
<label key={art} className={`flex items-center gap-1 px-3 py-1 border rounded-full cursor-pointer transition-colors text-sm ${
|
||||
filterArbeitArten.includes(art)
|
||||
? 'bg-blue-100 border-blue-300 text-blue-800'
|
||||
: 'bg-white border-gray-300 hover:bg-gray-50'
|
||||
}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filterArbeitArten.includes(art)}
|
||||
onChange={(e) => {
|
||||
setFilterArbeitArten(prev =>
|
||||
e.target.checked
|
||||
? [...prev, art]
|
||||
: prev.filter(a => a !== art)
|
||||
);
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
{art}
|
||||
{filterArbeitArten.includes(art) && <CheckSquare size={14} className="ml-1" />}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left py-2 px-4">Datum</th>
|
||||
<th className="text-left py-2 px-4">Kunde</th>
|
||||
<th className="text-left py-2 px-4">Art der Arbeit</th>
|
||||
<th className="text-center py-2 px-4">PDF</th>
|
||||
<th className="py-2 px-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{auftragsnachweise
|
||||
.filter(an => {
|
||||
// Kunde Filter
|
||||
if (filterKunde && an.kunde_id !== parseInt(filterKunde) && an.kunde_name !== kunden.find(k => k.id === parseInt(filterKunde))?.name) return false;
|
||||
|
||||
// Datum Filter
|
||||
const anDatum = new Date(an.datum);
|
||||
if (filterDatumVon && anDatum < new Date(filterDatumVon)) return false;
|
||||
if (filterDatumBis) {
|
||||
const bisDatum = new Date(filterDatumBis);
|
||||
bisDatum.setHours(23, 59, 59);
|
||||
if (anDatum > bisDatum) return false;
|
||||
}
|
||||
|
||||
// Art der Arbeit Filter
|
||||
if (filterArbeitArten.length > 0) {
|
||||
const anArbeiten = Array.isArray(an.art_der_arbeit) ? an.art_der_arbeit : (an.art_der_arbeit ? [an.art_der_arbeit] : []);
|
||||
if (!filterArbeitArten.some(art => anArbeiten.includes(art))) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => new Date(b.datum) - new Date(a.datum))
|
||||
.map(an => (
|
||||
<tr key={an.id} className="border-b border-gray-100">
|
||||
<td className="py-2 px-4">{new Date(an.datum).toLocaleDateString('de-DE')}</td>
|
||||
<td className="py-2 px-4 font-medium">{an.kunde_name || 'Unbekannt'}</td>
|
||||
<td className="py-2 px-4 text-gray-600">
|
||||
{Array.isArray(an.art_der_arbeit) ? an.art_der_arbeit.join(', ') : (an.art_der_arbeit || '-')}
|
||||
</td>
|
||||
<td className="text-center py-2 px-4">
|
||||
{an.pdf_pfad ? (
|
||||
<button
|
||||
onClick={() => downloadPDF(an.id)}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
title="PDF herunterladen"
|
||||
>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-4">
|
||||
<button
|
||||
onClick={() => deleteAuftragsnachweis(an.id)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { LayoutDashboard, Clock, CreditCard, Home, TrendingUp, DollarSign, FileText, AlertCircle, Wallet, BarChart3, Building2, Briefcase, Users, Calculator } from 'lucide-react';
|
||||
import { krediteAPI, stundenAPI, kostenplanungAPI, geschaeftsplanungAPI } from '../api';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [stunden, setStunden] = useState([]);
|
||||
const [kredite, setKredite] = useState([]);
|
||||
const [planung, setPlanung] = useState([]);
|
||||
const [geschaeftsDaten, setGeschaeftsDaten] = useState(null);
|
||||
const [zahlungen, setZahlungen] = useState({});
|
||||
const [buchungen, setBuchungen] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardData();
|
||||
}, []);
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const [stundenData, krediteData, planungData, geschaeftsDaten] = await Promise.all([
|
||||
stundenAPI.getAll(),
|
||||
krediteAPI.getAll(),
|
||||
kostenplanungAPI.getAll(),
|
||||
geschaeftsplanungAPI.getMonatlich(currentYear).catch(() => null)
|
||||
]);
|
||||
|
||||
setStunden(stundenData);
|
||||
setKredite(krediteData);
|
||||
setPlanung(planungData);
|
||||
setGeschaeftsDaten(geschaeftsDaten);
|
||||
|
||||
// Lade Zahlungen für alle Kredite
|
||||
const zahlungenPromises = krediteData.map(k =>
|
||||
krediteAPI.getZahlungen(k.id).catch(() => [])
|
||||
);
|
||||
const zahlungenResults = await Promise.all(zahlungenPromises);
|
||||
const zahlungenMap = {};
|
||||
krediteData.forEach((k, idx) => {
|
||||
zahlungenMap[k.id] = zahlungenResults[idx];
|
||||
});
|
||||
setZahlungen(zahlungenMap);
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden der Daten: ' + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fallback: stelle sicher, dass zahlungen immer definiert ist
|
||||
const zahlungenData = zahlungen || {};
|
||||
|
||||
// EINFACHE Restschuld-Berechnung: Direkt aus DB nehmen
|
||||
const berechneRestschuld = (kredit) => {
|
||||
if (!kredit) return 0;
|
||||
// Verwende den gespeicherten Restschuld-Wert aus der Datenbank
|
||||
// (wie in Kredite.jsx vor der dynamischen Berechnung)
|
||||
return parseFloat(kredit.restschuld) || parseFloat(kredit.ursprungsschuld) || 0;
|
||||
};
|
||||
|
||||
// Berechne aktuelle monatliche Rate basierend auf der Restschuld
|
||||
const berechneAktuelleRate = (kredit) => {
|
||||
if (!kredit || !kredit.monatsrate) return 0;
|
||||
|
||||
const restschuld = berechneRestschuld(kredit);
|
||||
if (restschuld <= 0) return 0;
|
||||
|
||||
// Wenn die Restschuld kleiner als die Rate ist, zeige die Restschuld als Rate
|
||||
return Math.min(restschuld, parseFloat(kredit.monatsrate));
|
||||
};
|
||||
|
||||
// Berechne nächste Fälligkeit
|
||||
const berechneNaechsteFaelligkeit = (kredit) => {
|
||||
if (!kredit || !kredit.faelligkeit_tag) return null;
|
||||
|
||||
const today = new Date();
|
||||
const currentMonth = today.getMonth();
|
||||
const currentYear = today.getFullYear();
|
||||
const day = kredit.faelligkeit_tag;
|
||||
|
||||
// Versuche diesen Monat
|
||||
let faelligkeit = new Date(currentYear, currentMonth, day);
|
||||
if (faelligkeit < today) {
|
||||
// Nächster Monat
|
||||
faelligkeit = new Date(currentYear, currentMonth + 1, day);
|
||||
}
|
||||
|
||||
return faelligkeit;
|
||||
};
|
||||
|
||||
// Formatiere Fälligkeit für Anzeige
|
||||
const formatiereFaelligkeit = (datum) => {
|
||||
if (!datum) return '';
|
||||
const heute = new Date();
|
||||
const diffTage = Math.ceil((datum - heute) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffTage === 0) return 'Heute';
|
||||
if (diffTage === 1) return 'Morgen';
|
||||
if (diffTage <= 7) return `in ${diffTage} Tagen`;
|
||||
return datum.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
||||
};
|
||||
|
||||
// Berechnungen
|
||||
const offeneStunden = stunden.filter(s => s.status === 'offen');
|
||||
const abgerechneteStunden = stunden.filter(s => s.status === 'abgerechnet');
|
||||
const totalOffen = offeneStunden.reduce((sum, s) => sum + parseFloat(s.betrag || 0), 0);
|
||||
const totalAbgerechnet = abgerechneteStunden.reduce((sum, s) => sum + parseFloat(s.betrag || 0), 0);
|
||||
|
||||
// Berechne Gesamtschuld MIT Zinsen - unterscheide nach Richtung
|
||||
// WICHTIG: richtung kann 'eingehend' (Forderung) oder 'ausgehend' (Schulden) sein
|
||||
const { schulden, forderungen } = kredite.reduce((acc, k) => {
|
||||
const restschuld = berechneRestschuld(k);
|
||||
const richtung = k.richtung || 'ausgehend'; // Default: ausgehend (Schulden)
|
||||
|
||||
if (richtung === 'eingehend') {
|
||||
// Eingehend = Forderung/Guthaben (jemand schuldet mir)
|
||||
acc.forderungen += restschuld;
|
||||
} else {
|
||||
// Ausgehend = Schulden (ich schulde jemandem) oder Default
|
||||
acc.schulden += restschuld;
|
||||
}
|
||||
return acc;
|
||||
}, { schulden: 0, forderungen: 0 });
|
||||
|
||||
// Kredit-Dashboard-Daten
|
||||
const krediteAktiv = kredite.filter(k => berechneRestschuld(k) > 0);
|
||||
const kreditRestschuldTotal = krediteAktiv.reduce((sum, k) => {
|
||||
const richtung = k.richtung || 'ausgehend';
|
||||
const restschuld = berechneRestschuld(k);
|
||||
// Nur ausgehende Kredite (Schulden) zählen
|
||||
if (richtung !== 'eingehend') {
|
||||
return sum + restschuld;
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
// Monatliche Kredit-Belastung (nur Schulden)
|
||||
const monatlicheKreditBelastung = krediteAktiv.reduce((sum, k) => {
|
||||
const richtung = k.richtung || 'ausgehend';
|
||||
if (richtung !== 'eingehend') {
|
||||
return sum + berechneAktuelleRate(k);
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
// Nächste Fälligkeit finden
|
||||
const naechsteFaelligkeit = krediteAktiv
|
||||
.filter(k => (k.richtung || 'ausgehend') !== 'eingehend')
|
||||
.map(k => ({
|
||||
kredit: k,
|
||||
faelligkeit: berechneNaechsteFaelligkeit(k),
|
||||
rate: berechneAktuelleRate(k)
|
||||
}))
|
||||
.filter(k => k.faelligkeit && k.rate > 0)
|
||||
.sort((a, b) => a.faelligkeit - b.faelligkeit)[0];
|
||||
|
||||
// Debug-Log für Entwicklung
|
||||
console.log('Dashboard Kredite:', kredite.map(k => ({
|
||||
name: k.name,
|
||||
richtung: k.richtung,
|
||||
restschuld: berechneRestschuld(k)
|
||||
})));
|
||||
console.log('Berechnet:', { schulden, forderungen });
|
||||
|
||||
// Für Abwärtskompatibilität: totalSchulden = Schulden - Forderungen (Nettoschuld)
|
||||
const totalSchulden = schulden - forderungen;
|
||||
|
||||
// Für die Anzeige: Betrag (immer positiv) und ob Schulden oder Forderungen
|
||||
const isNettoSchulden = totalSchulden > 0;
|
||||
const anzeigeBetrag = Math.abs(totalSchulden);
|
||||
|
||||
const aktuellesJahr = new Date().getFullYear();
|
||||
const aktuellePlanung = planung.filter(p => p.jahr === aktuellesJahr);
|
||||
|
||||
// Berechne monatliche Summen
|
||||
const monatlicheSummen = Array(12).fill(0);
|
||||
aktuellePlanung.forEach(p => {
|
||||
(p.monate || []).forEach((val, idx) => {
|
||||
if (idx < 12) monatlicheSummen[idx] += parseFloat(val) || 0;
|
||||
});
|
||||
});
|
||||
const durchschnittlichePlanung = monatlicheSummen.reduce((a, b) => a + b, 0) / 12;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Lade Dashboard...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Anzahl Kredite nach Richtung (mit Fallback auf 'ausgehend')
|
||||
const anzahlSchulden = kredite.filter(k => (k.richtung || 'ausgehend') !== 'eingehend').length;
|
||||
const anzahlForderungen = kredite.filter(k => (k.richtung || 'ausgehend') === 'eingehend').length;
|
||||
|
||||
// Monatliche Raten nur für ausgehende Kredite (Schulden)
|
||||
const monatlicheRaten = kredite
|
||||
.filter(k => (k.richtung || 'ausgehend') !== 'eingehend')
|
||||
.reduce((sum, k) => sum + berechneAktuelleRate(k), 0);
|
||||
|
||||
const karten = [
|
||||
{
|
||||
icon: FileText,
|
||||
title: 'Dokumente',
|
||||
value: 'OCR + Upload',
|
||||
color: 'bg-blue-500',
|
||||
link: 'docs'
|
||||
},
|
||||
{
|
||||
icon: CreditCard,
|
||||
title: 'Kredit-Restschuld',
|
||||
value: kreditRestschuldTotal.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }),
|
||||
subtitle: `${krediteAktiv.filter(k => (k.richtung || 'ausgehend') !== 'eingehend').length} aktive Kredite`,
|
||||
color: 'bg-red-500',
|
||||
link: 'kredite'
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
title: 'Offene Stunden',
|
||||
value: totalOffen.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }),
|
||||
subtitle: `${offeneStunden.length} Einträge`,
|
||||
color: 'bg-orange-500',
|
||||
link: 'stunden'
|
||||
},
|
||||
{
|
||||
icon: Home,
|
||||
title: 'Planung',
|
||||
value: aktuellePlanung.length + ' Positionen',
|
||||
color: 'bg-green-500',
|
||||
link: 'planung'
|
||||
}
|
||||
];
|
||||
|
||||
// Geschäftsplanung-Daten für Dashboard
|
||||
const geschaeftsKarten = geschaeftsDaten?.summen ? [
|
||||
{
|
||||
icon: TrendingUp,
|
||||
title: 'Einnahmen (Plan)',
|
||||
value: (geschaeftsDaten.summen.einnahmen || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }),
|
||||
color: 'bg-emerald-500',
|
||||
link: 'planung'
|
||||
},
|
||||
{
|
||||
icon: DollarSign,
|
||||
title: 'Betriebsergebnis',
|
||||
value: (geschaeftsDaten.summen.betriebsergebnis || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }),
|
||||
color: (geschaeftsDaten.summen.betriebsergebnis || 0) >= 0 ? 'bg-blue-500' : 'bg-red-500',
|
||||
link: 'planung'
|
||||
}
|
||||
] : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-2 text-red-700">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button
|
||||
onClick={() => { setError(null); loadDashboardData(); }}
|
||||
className="ml-auto px-3 py-1 bg-red-600 text-white rounded hover:bg-red-700"
|
||||
>
|
||||
Neu laden
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Karten */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{karten.map((karte, idx) => {
|
||||
const Icon = karte.icon;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white rounded-lg shadow p-6 cursor-pointer hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className={`${karte.color} w-12 h-12 rounded-lg flex items-center justify-center mb-4`}>
|
||||
<Icon className="text-white" size={24} />
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm">{karte.title}</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{karte.value}</p>
|
||||
{karte.subtitle && <p className="text-sm text-gray-500">{karte.subtitle}</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Übersichten */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Kredite Übersicht - NEU */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<CreditCard size={20} />
|
||||
Kredit-Status
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* Kredit-Restschuld */}
|
||||
<div className="flex justify-between items-center p-3 bg-red-50 rounded-lg">
|
||||
<div>
|
||||
<span className="text-sm text-gray-600 block">Kredit-Restschuld (aktuell)</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{krediteAktiv.filter(k => (k.richtung || 'ausgehend') !== 'eingehend').length} aktive Kredite
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-bold text-xl text-red-600">
|
||||
{kreditRestschuldTotal.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Monatliche Kredit-Belastung */}
|
||||
<div className="flex justify-between items-center p-3 bg-orange-50 rounded-lg">
|
||||
<div>
|
||||
<span className="text-sm text-gray-600 block">Monatliche Kredit-Belastung</span>
|
||||
<span className="text-xs text-gray-500">Summe aller Raten diesen Monat</span>
|
||||
</div>
|
||||
<span className="font-bold text-xl text-orange-600">
|
||||
{monatlicheKreditBelastung.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Nächste Fälligkeit */}
|
||||
{naechsteFaelligkeit && naechsteFaelligkeit.kredit && (
|
||||
<div className="flex justify-between items-center p-3 bg-blue-50 rounded-lg">
|
||||
<div>
|
||||
<span className="text-sm text-gray-600 block">Nächste Fälligkeit</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{naechsteFaelligkeit.kredit?.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="font-bold text-lg text-blue-600 block">
|
||||
{(naechsteFaelligkeit.rate || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</span>
|
||||
<span className="text-xs text-blue-500">
|
||||
{formatiereFaelligkeit(naechsteFaelligkeit.faelligkeit)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Forderungen anzeigen falls vorhanden */}
|
||||
{forderungen > 0 && (
|
||||
<div className="flex justify-between items-center p-3 bg-green-50 rounded-lg">
|
||||
<span className="text-sm text-gray-600">Meine Forderungen</span>
|
||||
<span className="font-bold text-green-600">
|
||||
{forderungen.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stunden Übersicht */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Clock size={20} />
|
||||
Stunden Übersicht
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Abgerechnet</span>
|
||||
<span className="font-semibold text-green-600">{totalAbgerechnet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Offen</span>
|
||||
<span className="font-semibold text-orange-600">{totalOffen.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Gesamt</span>
|
||||
<span className="font-semibold">{(totalAbgerechnet + totalOffen).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Geschäftsplanung Dashboard-Kacheln */}
|
||||
{geschaeftsKarten.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
|
||||
{geschaeftsKarten.map((karte, idx) => {
|
||||
const Icon = karte.icon;
|
||||
return (
|
||||
<div
|
||||
key={`gp-${idx}`}
|
||||
className="bg-white rounded-lg shadow p-6 cursor-pointer hover:shadow-lg transition-shadow border-l-4 border-blue-500"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`${karte.color} w-12 h-12 rounded-lg flex items-center justify-center`}>
|
||||
<Icon className="text-white" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-600 text-sm">{karte.title}</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{karte.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kostenplanung Vorschau */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<TrendingUp size={20} />
|
||||
Geschäftsplanung {aktuellesJahr}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{geschaeftsDaten?.summen ? (
|
||||
<>
|
||||
<div className="bg-emerald-50 rounded p-3">
|
||||
<p className="text-sm text-emerald-600">Einnahmen</p>
|
||||
<p className="text-xl font-bold text-emerald-700">
|
||||
{(geschaeftsDaten.summen.einnahmen || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-rose-50 rounded p-3">
|
||||
<p className="text-sm text-rose-600">Betriebskosten</p>
|
||||
<p className="text-xl font-bold text-rose-700">
|
||||
{(geschaeftsDaten.summen.betriebskosten || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-blue-50 rounded p-3">
|
||||
<p className="text-sm text-blue-600">Betriebsergebnis</p>
|
||||
<p className={`text-xl font-bold ${(geschaeftsDaten.summen.betriebsergebnis || 0) >= 0 ? 'text-blue-700' : 'text-red-700'}`}>
|
||||
{(geschaeftsDaten.summen.betriebsergebnis || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-amber-50 rounded p-3">
|
||||
<p className="text-sm text-amber-600">Cashflow (nach privat)</p>
|
||||
<p className={`text-xl font-bold ${(geschaeftsDaten.summen.cashflow || 0) >= 0 ? 'text-amber-700' : 'text-red-700'}`}>
|
||||
{(geschaeftsDaten.summen.cashflow || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-gray-500 col-span-4">Keine Geschäftsplanung vorhanden</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FileText, Upload, Eye, Trash2 } from 'lucide-react';
|
||||
|
||||
export default function Dokumente({ API_URL }) {
|
||||
const [docs, setDocs] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
year: new Date().getFullYear(),
|
||||
category: 'steuer',
|
||||
amount: '',
|
||||
notes: ''
|
||||
});
|
||||
|
||||
const categories = [
|
||||
{ id: 'steuer', name: 'Steuer (Elster)', color: 'bg-blue-100 text-blue-800' },
|
||||
{ id: 'nebenkosten', name: 'Nebenkosten (Wohngeld)', color: 'bg-green-100 text-green-800' },
|
||||
{ id: 'kredit', name: 'Kredit/Finanzierung', color: 'bg-purple-100 text-purple-800' },
|
||||
{ id: 'hausmeister', name: 'Hausmeister/Instandhaltung', color: 'bg-orange-100 text-orange-800' },
|
||||
{ id: 'gewerbe', name: 'Gewerbe', color: 'bg-red-100 text-red-800' },
|
||||
{ id: 'sonstiges', name: 'Sonstiges', color: 'bg-gray-100 text-gray-800' }
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocs();
|
||||
}, []);
|
||||
|
||||
const fetchDocs = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/docs`);
|
||||
const data = await res.json();
|
||||
setDocs(data);
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
// Upload-Logik hier
|
||||
};
|
||||
|
||||
const deleteDoc = async (id) => {
|
||||
if (!confirm('Dokument wirklich löschen?')) return;
|
||||
try {
|
||||
await fetch(`${API_URL}/api/docs/${id}`, { method: 'DELETE' });
|
||||
fetchDocs();
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Löschen:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Upload-Formular */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Upload size={20} />
|
||||
Dokument hochladen
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Jahr</label>
|
||||
<select
|
||||
value={form.year}
|
||||
onChange={(e) => setForm({ ...form, year: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
>
|
||||
{[2024, 2025, 2026, 2027].map(y => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||
<select
|
||||
value={form.category}
|
||||
onChange={(e) => setForm({ ...form, category: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Betrag (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.amount}
|
||||
onChange={(e) => setForm({ ...form, amount: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Datei</label>
|
||||
<input
|
||||
type="file"
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notizen</label>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
rows="2"
|
||||
placeholder="Optionale Notizen..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 disabled:bg-gray-400"
|
||||
>
|
||||
{loading ? 'Wird hochgeladen...' : '📤 Hochladen'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Dokumenten-Liste */}
|
||||
{docs.length > 0 ? (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4">Datei</th>
|
||||
<th className="text-left py-3 px-4">Kategorie</th>
|
||||
<th className="text-left py-3 px-4">Jahr</th>
|
||||
<th className="text-right py-3 px-4">Betrag</th>
|
||||
<th className="py-3 px-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{docs.map(doc => {
|
||||
const cat = categories.find(c => c.id === doc.category);
|
||||
return (
|
||||
<tr key={doc.id} className="border-b border-gray-100">
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={16} />
|
||||
{doc.filename}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${cat?.color || 'bg-gray-100'}`}>
|
||||
{cat?.name || doc.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">{doc.year}</td>
|
||||
<td className="text-right py-3 px-4">
|
||||
{doc.amount > 0 && `${parseFloat(doc.amount).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}`}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex gap-2">
|
||||
<button className="text-blue-400 hover:text-blue-600">
|
||||
<Eye size={18} />
|
||||
</button>
|
||||
<button onClick={() => deleteDoc(doc.id)} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center text-gray-500">
|
||||
Noch keine Dokumente hochgeladen.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useState, useEffect, Fragment } from 'react';
|
||||
import { Plus, Trash2, Save, AlertCircle, X, Clock } from 'lucide-react';
|
||||
import { kostenplanungAPI, stundenAPI } from '../api';
|
||||
|
||||
const MONATE = [
|
||||
{ key: 0, name: 'Jan' },
|
||||
{ key: 1, name: 'Feb' },
|
||||
{ key: 2, name: 'Mär' },
|
||||
{ key: 3, name: 'Apr' },
|
||||
{ key: 4, name: 'Mai' },
|
||||
{ key: 5, name: 'Jun' },
|
||||
{ key: 6, name: 'Jul' },
|
||||
{ key: 7, name: 'Aug' },
|
||||
{ key: 8, name: 'Sep' },
|
||||
{ key: 9, name: 'Okt' },
|
||||
{ key: 10, name: 'Nov' },
|
||||
{ key: 11, name: 'Dez' }
|
||||
];
|
||||
|
||||
const KATEGORIEN = ['Einnahmen', 'Betriebskosten'];
|
||||
const OBJEKTE = ['Firma']; // Nur noch Firma, keine Vermietungsobjekte
|
||||
|
||||
export default function Geschaeftsplanung() {
|
||||
const [planung, setPlanung] = useState([]);
|
||||
const [stundenEinnahmen, setStundenEinnahmen] = useState(Array(12).fill(0));
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedObjekt, setSelectedObjekt] = useState('Firma');
|
||||
const [selectedJahr, setSelectedJahr] = useState(2026);
|
||||
const [showAddRow, setShowAddRow] = useState(false);
|
||||
|
||||
const [newRow, setNewRow] = useState({
|
||||
name: '',
|
||||
kategorie: 'Betriebskosten',
|
||||
typ: 'gewerbe',
|
||||
istEinnahme: false,
|
||||
monate: Array(12).fill(''),
|
||||
objekt: 'Firma'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [selectedObjekt, selectedJahr]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Lade Geschäftsplanung
|
||||
const data = await kostenplanungAPI.getAll({ objekt: selectedObjekt, jahr: selectedJahr });
|
||||
|
||||
// Lade abgerechnete Stunden für automatische Einnahmen
|
||||
const stundenData = await stundenAPI.getAll({
|
||||
jahr: selectedJahr,
|
||||
status: 'abgerechnet'
|
||||
});
|
||||
|
||||
// Berechne Stundeneinnahmen pro Monat
|
||||
const monatlicheStunden = Array(12).fill(0);
|
||||
stundenData.forEach(s => {
|
||||
const stundenDatum = new Date(s.datum);
|
||||
if (stundenDatum.getFullYear() === selectedJahr) {
|
||||
const monat = stundenDatum.getMonth();
|
||||
const stunden = parseFloat(s.stunden) || 0;
|
||||
const stundensatz = parseFloat(s.stundensatz) || 80; // Default 80€
|
||||
monatlicheStunden[monat] += stunden * stundensatz;
|
||||
}
|
||||
});
|
||||
|
||||
setStundenEinnahmen(monatlicheStunden);
|
||||
setPlanung(data.map(p => ({
|
||||
...p,
|
||||
monate: p.monate || Array(12).fill(0)
|
||||
})));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden: ' + err.message);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const berechneSumme = (monate) => {
|
||||
return (monate || []).reduce((sum, val) => sum + (parseFloat(val) || 0), 0);
|
||||
};
|
||||
|
||||
const addRow = async () => {
|
||||
if (!newRow.name) return;
|
||||
|
||||
try {
|
||||
const data = {
|
||||
objekt: selectedObjekt,
|
||||
name: newRow.name,
|
||||
kategorie: newRow.kategorie,
|
||||
typ: newRow.typ,
|
||||
ist_einnahme: newRow.istEinnahme,
|
||||
monate: newRow.monate.map(v => parseFloat(v) || 0),
|
||||
jahr: selectedJahr
|
||||
};
|
||||
|
||||
await kostenplanungAPI.create(data);
|
||||
await loadData();
|
||||
|
||||
setNewRow({
|
||||
name: '',
|
||||
kategorie: 'Betriebskosten',
|
||||
typ: 'gewerbe',
|
||||
istEinnahme: false,
|
||||
monate: Array(12).fill(''),
|
||||
objekt: selectedObjekt
|
||||
});
|
||||
setShowAddRow(false);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Speichern: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const updateMonat = async (id, monatIndex, wert) => {
|
||||
try {
|
||||
const p = planung.find(item => item.id === id);
|
||||
const neueMonate = [...(p.monate || Array(12).fill(0))];
|
||||
neueMonate[monatIndex] = parseFloat(wert) || 0;
|
||||
|
||||
await kostenplanungAPI.update(id, { monate: neueMonate });
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Aktualisieren: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRow = async (id) => {
|
||||
if (confirm('Position wirklich löschen?')) {
|
||||
try {
|
||||
await kostenplanungAPI.delete(id);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Löschen: ' + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getPlanungByKategorie = (kategorieName) => {
|
||||
return planung.filter(p => p.kategorie === kategorieName);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Lade Geschäftsplanung...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-blue-600 rounded-lg p-6 text-white">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Geschäftsplanung</h2>
|
||||
<p className="text-blue-200 mt-1">Firma · Einnahmen · Betriebskosten</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={selectedJahr}
|
||||
onChange={(e) => setSelectedJahr(parseInt(e.target.value))}
|
||||
className="bg-white/10 border border-white/20 rounded px-3 py-2 text-white"
|
||||
>
|
||||
<option value={2025} className="text-gray-900">2025</option>
|
||||
<option value={2026} className="text-gray-900">2026</option>
|
||||
<option value={2027} className="text-gray-900">2027</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-2 text-red-700">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-auto">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowAddRow(!showAddRow)}
|
||||
className="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus size={20} />
|
||||
{showAddRow ? 'Abbrechen' : 'Neue Position hinzufügen'}
|
||||
</button>
|
||||
|
||||
{showAddRow && (
|
||||
<div className="bg-white rounded-lg shadow p-6 border-2 border-blue-200">
|
||||
<h3 className="text-lg font-semibold mb-4">Neue Position</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Bezeichnung</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRow.name}
|
||||
onChange={(e) => setNewRow({ ...newRow, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="z.B. Einrichtung"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Objekt</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRow.objekt}
|
||||
onChange={(e) => setNewRow({ ...newRow, objekt: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="z.B. Firma, Wilster, Segeberg..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-6 md:grid-cols-12 gap-2 mb-4">
|
||||
{MONATE.map((m) => (
|
||||
<div key={m.key} className="text-center">
|
||||
<label className="block text-xs text-gray-500 mb-1">{m.name}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={newRow.monate[m.key]}
|
||||
onChange={(e) => {
|
||||
const neueMonate = [...newRow.monate];
|
||||
neueMonate[m.key] = e.target.value;
|
||||
setNewRow({ ...newRow, monate: neueMonate });
|
||||
}}
|
||||
className="w-full border border-gray-200 rounded px-1 py-1 text-center text-xs"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={addRow}
|
||||
className="bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700 flex items-center gap-2"
|
||||
>
|
||||
<Save size={16} /> Speichern
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-100 border-b-2 border-gray-300">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-semibold">Name</th>
|
||||
<th className="text-left py-3 px-4 font-semibold">Objekt</th>
|
||||
{MONATE.map(m => (
|
||||
<th key={m.key} className="text-center py-3 px-1 font-semibold">{m.name}</th>
|
||||
))}
|
||||
<th className="text-right py-3 px-4 font-semibold">Summe</th>
|
||||
<th className="py-3 px-2 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{KATEGORIEN.map(kategorie => {
|
||||
const positionen = getPlanungByKategorie(kategorie);
|
||||
|
||||
// Automatische Stunden-Einnahmen für Kategorie "Einnahmen"
|
||||
const zeigeStundenEinnahmen = kategorie === 'Einnahmen' && stundenEinnahmen.some(e => e > 0);
|
||||
|
||||
if (positionen.length === 0 && !zeigeStundenEinnahmen) return null;
|
||||
|
||||
return (
|
||||
<Fragment key={kategorie}>
|
||||
<tr className="bg-gray-50 border-t border-gray-200">
|
||||
<td colSpan={15} className="py-2 px-4 font-bold text-gray-700">
|
||||
{kategorie}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Automatische Stunden-Einnahmen */}
|
||||
{zeigeStundenEinnahmen && (
|
||||
<tr className="border-b border-gray-100 bg-emerald-50">
|
||||
<td className="py-2 px-4 font-medium flex items-center gap-2">
|
||||
<Clock size={16} className="text-emerald-600" />
|
||||
Abgerechnete Stunden (auto)
|
||||
</td>
|
||||
<td className="py-2 px-4 text-gray-600">-</td>
|
||||
{MONATE.map((m) => (
|
||||
<td key={m.key} className="text-center py-1 px-1 text-emerald-600 font-semibold">
|
||||
{stundenEinnahmen[m.key] > 0
|
||||
? stundenEinnahmen[m.key].toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })
|
||||
: '-'
|
||||
}
|
||||
</td>
|
||||
))}
|
||||
<td className="text-right py-2 px-4 font-bold text-emerald-600">
|
||||
{stundenEinnahmen.reduce((a, b) => a + b, 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<span className="text-xs text-gray-400">Auto</span>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{positionen.map(p => (
|
||||
<tr key={p.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="py-2 px-4 font-medium">{p.name}</td>
|
||||
<td className="py-2 px-4 text-gray-600">{p.objekt || '-'}</td>
|
||||
{MONATE.map((m) => (
|
||||
<td key={m.key} className="text-center py-1 px-1">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={(p.monate && p.monate[m.key]) || ''}
|
||||
onChange={(e) => updateMonat(p.id, m.key, e.target.value)}
|
||||
className="w-full border border-gray-200 rounded px-1 py-1 text-center text-xs"
|
||||
placeholder="-"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="text-right py-2 px-4 font-semibold">
|
||||
{berechneSumme(p.monate).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<button
|
||||
onClick={() => deleteRow(p.id)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{planung.length === 0 && (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
Keine Einträge vorhanden. Klicke auf "Neue Position hinzufügen".
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
import { useState, useEffect, Fragment } from 'react';
|
||||
import { Plus, Trash2, Save, Calculator, Building2, AlertCircle, X } from 'lucide-react';
|
||||
import { kostenplanungAPI } from '../api';
|
||||
|
||||
export default function Kostenplanung() {
|
||||
const [planung, setPlanung] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedObjekt, setSelectedObjekt] = useState('Wilster');
|
||||
const [showAddRow, setShowAddRow] = useState(false);
|
||||
const [newRow, setNewRow] = useState({
|
||||
name: '',
|
||||
kategorie: 'Betriebskosten',
|
||||
monate: Array(12).fill('')
|
||||
});
|
||||
|
||||
const objekte = ['Wilster', 'Segeberg', 'Allgemein'];
|
||||
|
||||
const kategorien = [
|
||||
{ id: 'Einnahmen', name: 'Einnahmen', color: 'bg-green-50', textColor: 'text-green-700' },
|
||||
{ id: 'Betriebskosten', name: 'Betriebskosten', color: 'bg-red-50', textColor: 'text-red-700' },
|
||||
{ id: 'Ergebnis', name: 'Ergebnis', color: 'bg-blue-50', textColor: 'text-blue-700' }
|
||||
];
|
||||
|
||||
const monateNamen = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
|
||||
|
||||
// Lade Planung beim Start
|
||||
useEffect(() => {
|
||||
loadPlanung();
|
||||
}, []);
|
||||
|
||||
const loadPlanung = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await kostenplanungAPI.getAll();
|
||||
// Transformiere Daten für Frontend
|
||||
const transformedData = data.map(p => ({
|
||||
id: p.id,
|
||||
objekt: p.objekt,
|
||||
name: p.name,
|
||||
kategorie: p.kategorie,
|
||||
monate: p.monate || Array(12).fill(0),
|
||||
jahr: p.jahr
|
||||
}));
|
||||
setPlanung(transformedData);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden: ' + err.message);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filtere Planung nach ausgewähltem Objekt
|
||||
const gefiltertePlanung = planung.filter(p => p.objekt === selectedObjekt);
|
||||
|
||||
// Gruppiere nach Kategorien
|
||||
const gruppiertePlanung = kategorien.reduce((gruppen, kat) => {
|
||||
gruppen[kat.id] = gefiltertePlanung.filter(p => p.kategorie === kat.id);
|
||||
return gruppen;
|
||||
}, {});
|
||||
|
||||
// Berechne Summen pro Zeile
|
||||
const berechneZeilenSumme = (monate) => {
|
||||
return (monate || Array(12).fill(0)).reduce((sum, val) => sum + (parseFloat(val) || 0), 0);
|
||||
};
|
||||
|
||||
// Berechne Summen pro Monat
|
||||
const berechneMonatsSummen = () => {
|
||||
const summen = {};
|
||||
|
||||
kategorien.forEach(kat => {
|
||||
summen[kat.id] = Array(12).fill(0);
|
||||
});
|
||||
|
||||
gefiltertePlanung.forEach(p => {
|
||||
if (p.kategorie !== 'Ergebnis' && summen[p.kategorie]) {
|
||||
(p.monate || []).forEach((val, idx) => {
|
||||
if (idx < 12) {
|
||||
summen[p.kategorie][idx] += parseFloat(val) || 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
summen['Ergebnis'] = summen['Einnahmen'].map((einnahme, idx) =>
|
||||
einnahme - summen['Betriebskosten'][idx]
|
||||
);
|
||||
|
||||
return summen;
|
||||
};
|
||||
|
||||
const monatsSummen = berechneMonatsSummen();
|
||||
|
||||
const gesamtSummen = kategorien.reduce((summen, kat) => {
|
||||
summen[kat.id] = (monatsSummen[kat.id] || []).reduce((sum, val) => sum + (val || 0), 0);
|
||||
return summen;
|
||||
}, {});
|
||||
|
||||
const addRow = async () => {
|
||||
if (!newRow.name) return;
|
||||
|
||||
try {
|
||||
const data = {
|
||||
objekt: selectedObjekt,
|
||||
name: newRow.name,
|
||||
kategorie: newRow.kategorie,
|
||||
monate: newRow.monate.map(v => parseFloat(v) || 0),
|
||||
jahr: new Date().getFullYear()
|
||||
};
|
||||
|
||||
await kostenplanungAPI.create(data);
|
||||
await loadPlanung();
|
||||
|
||||
setNewRow({
|
||||
name: '',
|
||||
kategorie: 'Betriebskosten',
|
||||
monate: Array(12).fill('')
|
||||
});
|
||||
setShowAddRow(false);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Speichern: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const updateRow = async (id, updates) => {
|
||||
try {
|
||||
const p = planung.find(item => item.id === id);
|
||||
const data = {
|
||||
objekt: updates.objekt || p.objekt,
|
||||
name: updates.name !== undefined ? updates.name : p.name,
|
||||
kategorie: updates.kategorie || p.kategorie,
|
||||
monate: updates.monate || p.monate,
|
||||
jahr: updates.jahr || p.jahr
|
||||
};
|
||||
|
||||
await kostenplanungAPI.update(id, data);
|
||||
await loadPlanung();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Aktualisieren: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const updateMonat = async (id, monatIndex, wert) => {
|
||||
try {
|
||||
const p = planung.find(item => item.id === id);
|
||||
const neueMonate = [...(p.monate || Array(12).fill(0))];
|
||||
neueMonate[monatIndex] = parseFloat(wert) || 0;
|
||||
|
||||
await kostenplanungAPI.update(id, { monate: neueMonate });
|
||||
await loadPlanung();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Aktualisieren: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRow = async (id) => {
|
||||
if (confirm('Position wirklich löschen?')) {
|
||||
try {
|
||||
await kostenplanungAPI.delete(id);
|
||||
await loadPlanung();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Löschen: ' + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const copyToAllMonths = async (id, wert) => {
|
||||
try {
|
||||
const val = parseFloat(wert) || 0;
|
||||
await updateRow(id, { monate: Array(12).fill(val) });
|
||||
} catch (err) {
|
||||
setError('Fehler beim Kopieren: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Lade Kostenplanung...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-2 text-red-700">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-auto">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header mit Objekt-Auswahl */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-blue-800 flex items-center gap-2">
|
||||
<Building2 size={20} />
|
||||
Kostenplanung
|
||||
</h3>
|
||||
<p className="text-sm text-blue-600">
|
||||
{selectedObjekt} • Jahr {new Date().getFullYear()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={selectedObjekt}
|
||||
onChange={(e) => setSelectedObjekt(e.target.value)}
|
||||
className="border border-gray-300 rounded px-3 py-2 bg-white"
|
||||
>
|
||||
{objekte.map(obj => (
|
||||
<option key={obj} value={obj}>{obj}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gesamtübersicht */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<p className="text-sm text-green-600">Gesamteinnahmen</p>
|
||||
<p className="text-2xl font-bold text-green-700">
|
||||
{(gesamtSummen['Einnahmen'] || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-sm text-red-600">Gesamtkosten</p>
|
||||
<p className="text-2xl font-bold text-red-700">
|
||||
{(gesamtSummen['Betriebskosten'] || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`border rounded-lg p-4 ${(gesamtSummen['Ergebnis'] || 0) >= 0 ? 'bg-blue-50 border-blue-200' : 'bg-orange-50 border-orange-200'}`}>
|
||||
<p className={`text-sm ${(gesamtSummen['Ergebnis'] || 0) >= 0 ? 'text-blue-600' : 'text-orange-600'}`}>Jahresergebnis</p>
|
||||
<p className={`text-2xl font-bold ${(gesamtSummen['Ergebnis'] || 0) >= 0 ? 'text-blue-700' : 'text-orange-700'}`}>
|
||||
{(gesamtSummen['Ergebnis'] || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Neue Position Button */}
|
||||
<button
|
||||
onClick={() => setShowAddRow(!showAddRow)}
|
||||
className="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus size={20} />
|
||||
{showAddRow ? 'Abbrechen' : 'Neue Position hinzufügen'}
|
||||
</button>
|
||||
|
||||
{/* Neues Position Formular */}
|
||||
{showAddRow && (
|
||||
<div className="bg-white rounded-lg shadow p-6 border-2 border-blue-200">
|
||||
<h3 className="text-lg font-semibold mb-4">Neue Position</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Bezeichnung</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRow.name}
|
||||
onChange={(e) => setNewRow({ ...newRow, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="z.B. Mieteinnahmen, Strom, Versicherung..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||
<select
|
||||
value={newRow.kategorie}
|
||||
onChange={(e) => setNewRow({ ...newRow, kategorie: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
>
|
||||
{kategorien.filter(k => k.id !== 'Ergebnis').map(kat => (
|
||||
<option key={kat.id} value={kat.id}>{kat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-6 md:grid-cols-12 gap-2 mb-4">
|
||||
{monateNamen.map((monat, idx) => (
|
||||
<div key={idx} className="text-center">
|
||||
<label className="block text-xs text-gray-500 mb-1">{monat}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={newRow.monate[idx]}
|
||||
onChange={(e) => {
|
||||
const neueMonate = [...newRow.monate];
|
||||
neueMonate[idx] = e.target.value;
|
||||
setNewRow({ ...newRow, monate: neueMonate });
|
||||
}}
|
||||
className="w-full border border-gray-200 rounded px-1 py-1 text-center text-xs"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={addRow}
|
||||
className="flex-1 bg-blue-600 text-white py-2 rounded hover:bg-blue-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Save size={16} /> Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const firstValue = newRow.monate[0];
|
||||
setNewRow({ ...newRow, monate: Array(12).fill(firstValue) });
|
||||
}}
|
||||
className="bg-gray-200 text-gray-700 px-4 py-2 rounded hover:bg-gray-300 flex items-center gap-2"
|
||||
>
|
||||
<Calculator size={16} /> Überall übernehmen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabelle */}
|
||||
<div className="bg-white rounded-lg shadow overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-100 border-b-2 border-gray-300">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-2 font-semibold min-w-[200px]">Position</th>
|
||||
{monateNamen.map((monat, idx) => (
|
||||
<th key={idx} className="text-center py-3 px-1 font-semibold min-w-[70px]">
|
||||
{idx + 1}.
|
||||
</th>
|
||||
))}
|
||||
<th className="text-right py-3 px-2 font-semibold min-w-[100px]">Summe</th>
|
||||
<th className="py-3 px-2 w-10"></th>
|
||||
</tr>
|
||||
<tr className="bg-gray-50 text-xs">
|
||||
<th className="py-2 px-2 text-gray-500"></th>
|
||||
{monateNamen.map((monat, idx) => (
|
||||
<th key={idx} className="text-center py-2 px-1 text-gray-500">
|
||||
{monat}
|
||||
</th>
|
||||
))}
|
||||
<th className="py-2 px-2"></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{kategorien.map(kat => (
|
||||
<Fragment key={kat.id}>
|
||||
{/* Kategorie-Header */}
|
||||
<tr className={`${kat.color} border-b border-gray-200`}>
|
||||
<td colSpan="15" className="py-2 px-2 font-bold">
|
||||
<span className={kat.textColor}>{kat.name}</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Positionen */}
|
||||
{kat.id === 'Ergebnis' ? (
|
||||
<tr className="bg-blue-100 font-bold border-b-2 border-blue-300">
|
||||
<td className="py-3 px-2">Ergebnis (Einnahmen - Kosten)</td>
|
||||
{(monatsSummen[kat.id] || []).map((summe, idx) => (
|
||||
<td key={idx} className={`text-center py-3 px-1 ${(summe || 0) >= 0 ? 'text-blue-700' : 'text-red-600'}`}>
|
||||
{(summe || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</td>
|
||||
))}
|
||||
<td className="text-right py-3 px-2">
|
||||
{(gesamtSummen[kat.id] || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
) : (
|
||||
<>
|
||||
{(gruppiertePlanung[kat.id] || []).map(p => (
|
||||
<tr key={p.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="py-2 px-2">
|
||||
<input
|
||||
type="text"
|
||||
value={p.name}
|
||||
onChange={(e) => updateRow(p.id, { name: e.target.value })}
|
||||
className="w-full bg-transparent border-none px-0 py-1 focus:ring-0 font-medium"
|
||||
/>
|
||||
</td>
|
||||
{(p.monate || []).map((wert, idx) => (
|
||||
<td key={idx} className="text-center py-1 px-1">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={wert || ''}
|
||||
onChange={(e) => updateMonat(p.id, idx, e.target.value)}
|
||||
className="w-full border border-gray-200 rounded px-1 py-1 text-center text-xs"
|
||||
placeholder="-"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="text-right py-2 px-2 font-semibold">
|
||||
{berechneZeilenSumme(p.monate).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<button
|
||||
onClick={() => deleteRow(p.id)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{/* Summe der Kategorie */}
|
||||
{(gruppiertePlanung[kat.id] || []).length > 0 && (
|
||||
<tr className={`${kat.color} border-b border-gray-300 font-semibold`}>
|
||||
<td className="py-2 px-2 text-gray-700">Summe {kat.name}</td>
|
||||
{(monatsSummen[kat.id] || []).map((summe, idx) => (
|
||||
<td key={idx} className={`text-center py-2 px-1 ${kat.textColor}`}>
|
||||
{(summe || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</td>
|
||||
))}
|
||||
<td className="text-right py-2 px-2">
|
||||
{(gesamtSummen[kat.id] || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{(gruppiertePlanung[kat.id] || []).length === 0 && (
|
||||
<tr className="border-b border-gray-100">
|
||||
<td colSpan="15" className="py-2 px-4 text-gray-400 text-sm italic">
|
||||
Keine Einträge in dieser Kategorie
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Hilfe / Hinweise */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-sm text-gray-600">
|
||||
<h4 className="font-semibold mb-2">Hinweise:</h4>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>Wählen Sie zuerst das Objekt aus (Wilster, Segeberg, etc.)</li>
|
||||
<li>Neue Positionen können über den Button "Neue Position hinzufügen" erstellt werden</li>
|
||||
<li>Die Ergebniszeile wird automatisch berechnet (Einnahmen - Betriebskosten)</li>
|
||||
<li>Alle Werte werden automatisch in der Datenbank gespeichert</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
import { useState, useEffect, Fragment } from 'react';
|
||||
import { Plus, Trash2, Save, Calculator, Building2, AlertCircle, X } from 'lucide-react';
|
||||
import { kostenplanungAPI } from '../api';
|
||||
|
||||
export default function Kostenplanung() {
|
||||
const [planung, setPlanung] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedObjekt, setSelectedObjekt] = useState('Wilster');
|
||||
const [showAddRow, setShowAddRow] = useState(false);
|
||||
const [newRow, setNewRow] = useState({
|
||||
name: '',
|
||||
kategorie: 'Betriebskosten',
|
||||
monate: Array(12).fill('')
|
||||
});
|
||||
|
||||
const objekte = ['Wilster', 'Segeberg', 'Allgemein'];
|
||||
|
||||
const kategorien = [
|
||||
{ id: 'Einnahmen', name: 'Einnahmen', color: 'bg-green-50', textColor: 'text-green-700' },
|
||||
{ id: 'Betriebskosten', name: 'Betriebskosten', color: 'bg-red-50', textColor: 'text-red-700' },
|
||||
{ id: 'Ergebnis', name: 'Ergebnis', color: 'bg-blue-50', textColor: 'text-blue-700' }
|
||||
];
|
||||
|
||||
const monateNamen = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
|
||||
|
||||
// Lade Planung beim Start
|
||||
useEffect(() => {
|
||||
loadPlanung();
|
||||
}, []);
|
||||
|
||||
const loadPlanung = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await kostenplanungAPI.getAll();
|
||||
// Transformiere Daten für Frontend
|
||||
const transformedData = data.map(p => ({
|
||||
id: p.id,
|
||||
objekt: p.objekt,
|
||||
name: p.name,
|
||||
kategorie: p.kategorie,
|
||||
monate: p.monate || Array(12).fill(0),
|
||||
jahr: p.jahr
|
||||
}));
|
||||
setPlanung(transformedData);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden: ' + err.message);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filtere Planung nach ausgewähltem Objekt
|
||||
const gefiltertePlanung = planung.filter(p => p.objekt === selectedObjekt);
|
||||
|
||||
// Gruppiere nach Kategorien
|
||||
const gruppiertePlanung = kategorien.reduce((gruppen, kat) => {
|
||||
gruppen[kat.id] = gefiltertePlanung.filter(p => p.kategorie === kat.id);
|
||||
return gruppen;
|
||||
}, {});
|
||||
|
||||
// Berechne Summen pro Zeile
|
||||
const berechneZeilenSumme = (monate) => {
|
||||
return (monate || Array(12).fill(0)).reduce((sum, val) => sum + (parseFloat(val) || 0), 0);
|
||||
};
|
||||
|
||||
// Berechne Summen pro Monat
|
||||
const berechneMonatsSummen = () => {
|
||||
const summen = {};
|
||||
|
||||
kategorien.forEach(kat => {
|
||||
summen[kat.id] = Array(12).fill(0);
|
||||
});
|
||||
|
||||
gefiltertePlanung.forEach(p => {
|
||||
if (p.kategorie !== 'Ergebnis' && summen[p.kategorie]) {
|
||||
(p.monate || []).forEach((val, idx) => {
|
||||
if (idx < 12) {
|
||||
summen[p.kategorie][idx] += parseFloat(val) || 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
summen['Ergebnis'] = summen['Einnahmen'].map((einnahme, idx) =>
|
||||
einnahme - summen['Betriebskosten'][idx]
|
||||
);
|
||||
|
||||
return summen;
|
||||
};
|
||||
|
||||
const monatsSummen = berechneMonatsSummen();
|
||||
|
||||
const gesamtSummen = kategorien.reduce((summen, kat) => {
|
||||
summen[kat.id] = (monatsSummen[kat.id] || []).reduce((sum, val) => sum + (val || 0), 0);
|
||||
return summen;
|
||||
}, {});
|
||||
|
||||
const addRow = async () => {
|
||||
if (!newRow.name) return;
|
||||
|
||||
try {
|
||||
const data = {
|
||||
objekt: selectedObjekt,
|
||||
name: newRow.name,
|
||||
kategorie: newRow.kategorie,
|
||||
monate: newRow.monate.map(v => parseFloat(v) || 0),
|
||||
jahr: new Date().getFullYear()
|
||||
};
|
||||
|
||||
await kostenplanungAPI.create(data);
|
||||
await loadPlanung();
|
||||
|
||||
setNewRow({
|
||||
name: '',
|
||||
kategorie: 'Betriebskosten',
|
||||
monate: Array(12).fill('')
|
||||
});
|
||||
setShowAddRow(false);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Speichern: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const updateRow = async (id, updates) => {
|
||||
try {
|
||||
const p = planung.find(item => item.id === id);
|
||||
const data = {
|
||||
objekt: updates.objekt || p.objekt,
|
||||
name: updates.name !== undefined ? updates.name : p.name,
|
||||
kategorie: updates.kategorie || p.kategorie,
|
||||
monate: updates.monate || p.monate,
|
||||
jahr: updates.jahr || p.jahr
|
||||
};
|
||||
|
||||
await kostenplanungAPI.update(id, data);
|
||||
await loadPlanung();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Aktualisieren: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const updateMonat = async (id, monatIndex, wert) => {
|
||||
try {
|
||||
const p = planung.find(item => item.id === id);
|
||||
const neueMonate = [...(p.monate || Array(12).fill(0))];
|
||||
neueMonate[monatIndex] = parseFloat(wert) || 0;
|
||||
|
||||
await kostenplanungAPI.update(id, { monate: neueMonate });
|
||||
await loadPlanung();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Aktualisieren: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRow = async (id) => {
|
||||
if (confirm('Position wirklich löschen?')) {
|
||||
try {
|
||||
await kostenplanungAPI.delete(id);
|
||||
await loadPlanung();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Löschen: ' + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const copyToAllMonths = async (id, wert) => {
|
||||
try {
|
||||
const val = parseFloat(wert) || 0;
|
||||
await updateRow(id, { monate: Array(12).fill(val) });
|
||||
} catch (err) {
|
||||
setError('Fehler beim Kopieren: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Lade Kostenplanung...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-2 text-red-700">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-auto">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header mit Objekt-Auswahl */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-blue-800 flex items-center gap-2">
|
||||
<Building2 size={20} />
|
||||
Kostenplanung
|
||||
</h3>
|
||||
<p className="text-sm text-blue-600">
|
||||
{selectedObjekt} • Jahr {new Date().getFullYear()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={selectedObjekt}
|
||||
onChange={(e) => setSelectedObjekt(e.target.value)}
|
||||
className="border border-gray-300 rounded px-3 py-2 bg-white"
|
||||
>
|
||||
{objekte.map(obj => (
|
||||
<option key={obj} value={obj}>{obj}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gesamtübersicht */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<p className="text-sm text-green-600">Gesamteinnahmen</p>
|
||||
<p className="text-2xl font-bold text-green-700">
|
||||
{(gesamtSummen['Einnahmen'] || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-sm text-red-600">Gesamtkosten</p>
|
||||
<p className="text-2xl font-bold text-red-700">
|
||||
{(gesamtSummen['Betriebskosten'] || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`border rounded-lg p-4 ${(gesamtSummen['Ergebnis'] || 0) >= 0 ? 'bg-blue-50 border-blue-200' : 'bg-orange-50 border-orange-200'}`}>
|
||||
<p className={`text-sm ${(gesamtSummen['Ergebnis'] || 0) >= 0 ? 'text-blue-600' : 'text-orange-600'}`}>Jahresergebnis</p>
|
||||
<p className={`text-2xl font-bold ${(gesamtSummen['Ergebnis'] || 0) >= 0 ? 'text-blue-700' : 'text-orange-700'}`}>
|
||||
{(gesamtSummen['Ergebnis'] || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Neue Position Button */}
|
||||
<button
|
||||
onClick={() => setShowAddRow(!showAddRow)}
|
||||
className="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus size={20} />
|
||||
{showAddRow ? 'Abbrechen' : 'Neue Position hinzufügen'}
|
||||
</button>
|
||||
|
||||
{/* Neues Position Formular */}
|
||||
{showAddRow && (
|
||||
<div className="bg-white rounded-lg shadow p-6 border-2 border-blue-200">
|
||||
<h3 className="text-lg font-semibold mb-4">Neue Position</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Bezeichnung</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRow.name}
|
||||
onChange={(e) => setNewRow({ ...newRow, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="z.B. Mieteinnahmen, Strom, Versicherung..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||
<select
|
||||
value={newRow.kategorie}
|
||||
onChange={(e) => setNewRow({ ...newRow, kategorie: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
>
|
||||
{kategorien.filter(k => k.id !== 'Ergebnis').map(kat => (
|
||||
<option key={kat.id} value={kat.id}>{kat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-6 md:grid-cols-12 gap-2 mb-4">
|
||||
{monateNamen.map((monat, idx) => (
|
||||
<div key={idx} className="text-center">
|
||||
<label className="block text-xs text-gray-500 mb-1">{monat}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={newRow.monate[idx]}
|
||||
onChange={(e) => {
|
||||
const neueMonate = [...newRow.monate];
|
||||
neueMonate[idx] = e.target.value;
|
||||
setNewRow({ ...newRow, monate: neueMonate });
|
||||
}}
|
||||
className="w-full border border-gray-200 rounded px-1 py-1 text-center text-xs"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={addRow}
|
||||
className="flex-1 bg-blue-600 text-white py-2 rounded hover:bg-blue-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Save size={16} /> Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const firstValue = newRow.monate[0];
|
||||
setNewRow({ ...newRow, monate: Array(12).fill(firstValue) });
|
||||
}}
|
||||
className="bg-gray-200 text-gray-700 px-4 py-2 rounded hover:bg-gray-300 flex items-center gap-2"
|
||||
>
|
||||
<Calculator size={16} /> Überall übernehmen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabelle */}
|
||||
<div className="bg-white rounded-lg shadow overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-100 border-b-2 border-gray-300">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-2 font-semibold min-w-[200px]">Position</th>
|
||||
{monateNamen.map((monat, idx) => (
|
||||
<th key={idx} className="text-center py-3 px-1 font-semibold min-w-[70px]">
|
||||
{idx + 1}.
|
||||
</th>
|
||||
))}
|
||||
<th className="text-right py-3 px-2 font-semibold min-w-[100px]">Summe</th>
|
||||
<th className="py-3 px-2 w-10"></th>
|
||||
</tr>
|
||||
<tr className="bg-gray-50 text-xs">
|
||||
<th className="py-2 px-2 text-gray-500"></th>
|
||||
{monateNamen.map((monat, idx) => (
|
||||
<th key={idx} className="text-center py-2 px-1 text-gray-500">
|
||||
{monat}
|
||||
</th>
|
||||
))}
|
||||
<th className="py-2 px-2"></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{kategorien.map(kat => (
|
||||
<Fragment key={kat.id}>
|
||||
{/* Kategorie-Header */}
|
||||
<tr className={`${kat.color} border-b border-gray-200`}>
|
||||
<td colSpan="15" className="py-2 px-2 font-bold">
|
||||
<span className={kat.textColor}>{kat.name}</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Positionen */}
|
||||
{kat.id === 'Ergebnis' ? (
|
||||
<tr className="bg-blue-100 font-bold border-b-2 border-blue-300">
|
||||
<td className="py-3 px-2">Ergebnis (Einnahmen - Kosten)</td>
|
||||
{(monatsSummen[kat.id] || []).map((summe, idx) => (
|
||||
<td key={idx} className={`text-center py-3 px-1 ${(summe || 0) >= 0 ? 'text-blue-700' : 'text-red-600'}`}>
|
||||
{(summe || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</td>
|
||||
))}
|
||||
<td className="text-right py-3 px-2">
|
||||
{(gesamtSummen[kat.id] || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
) : (
|
||||
<>
|
||||
{(gruppiertePlanung[kat.id] || []).map(p => (
|
||||
<tr key={p.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="py-2 px-2">
|
||||
<input
|
||||
type="text"
|
||||
value={p.name}
|
||||
onChange={(e) => updateRow(p.id, { name: e.target.value })}
|
||||
className="w-full bg-transparent border-none px-0 py-1 focus:ring-0 font-medium"
|
||||
/>
|
||||
</td>
|
||||
{(p.monate || []).map((wert, idx) => (
|
||||
<td key={idx} className="text-center py-1 px-1">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={wert || ''}
|
||||
onChange={(e) => updateMonat(p.id, idx, e.target.value)}
|
||||
className="w-full border border-gray-200 rounded px-1 py-1 text-center text-xs"
|
||||
placeholder="-"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="text-right py-2 px-2 font-semibold">
|
||||
{berechneZeilenSumme(p.monate).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<button
|
||||
onClick={() => deleteRow(p.id)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{/* Summe der Kategorie */}
|
||||
{(gruppiertePlanung[kat.id] || []).length > 0 && (
|
||||
<tr className={`${kat.color} border-b border-gray-300 font-semibold`}>
|
||||
<td className="py-2 px-2 text-gray-700">Summe {kat.name}</td>
|
||||
{(monatsSummen[kat.id] || []).map((summe, idx) => (
|
||||
<td key={idx} className={`text-center py-2 px-1 ${kat.textColor}`}>
|
||||
{(summe || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</td>
|
||||
))}
|
||||
<td className="text-right py-2 px-2">
|
||||
{(gesamtSummen[kat.id] || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{(gruppiertePlanung[kat.id] || []).length === 0 && (
|
||||
<tr className="border-b border-gray-100">
|
||||
<td colSpan="15" className="py-2 px-4 text-gray-400 text-sm italic">
|
||||
Keine Einträge in dieser Kategorie
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Hilfe / Hinweise */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-sm text-gray-600">
|
||||
<h4 className="font-semibold mb-2">Hinweise:</h4>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>Wählen Sie zuerst das Objekt aus (Wilster, Segeberg, etc.)</li>
|
||||
<li>Neue Positionen können über den Button "Neue Position hinzufügen" erstellt werden</li>
|
||||
<li>Die Ergebniszeile wird automatisch berechnet (Einnahmen - Betriebskosten)</li>
|
||||
<li>Alle Werte werden automatisch in der Datenbank gespeichert</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,500 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { CreditCard, Trash2, Edit2, Plus, X, Save, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
export default function Kredite({ kredite, setKredite }) {
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [expandedKredit, setExpandedKredit] = useState(null);
|
||||
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear());
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
betrag: '',
|
||||
zinssatz: '',
|
||||
startDatum: '',
|
||||
notizen: ''
|
||||
});
|
||||
|
||||
// Monatliche Berechnung für einen Kredit
|
||||
const berechneMonatlicheWerte = (kredit, bisDatum = new Date()) => {
|
||||
if (!kredit.startDatum) return [];
|
||||
|
||||
const start = new Date(kredit.startDatum + '-01');
|
||||
const ende = new Date(bisDatum);
|
||||
const monate = [];
|
||||
|
||||
let restschuld = kredit.betrag;
|
||||
let aktuellesDatum = new Date(start);
|
||||
|
||||
// Alle Zahlungen chronologisch sortieren
|
||||
const zahlungen = [...(kredit.zahlungen || [])].sort((a, b) => new Date(a.datum) - new Date(b.datum));
|
||||
let zahlungsIndex = 0;
|
||||
|
||||
while (aktuellesDatum <= ende) {
|
||||
const jahr = aktuellesDatum.getFullYear();
|
||||
const monat = aktuellesDatum.getMonth();
|
||||
|
||||
// Zinsen für diesen Monat
|
||||
const monatlicheZinsen = restschuld * (kredit.zinssatz / 100) / 12;
|
||||
|
||||
// Zahlungen für diesen Monat finden
|
||||
const monatsZahlungen = [];
|
||||
while (zahlungenIndex < zahlungen.length) {
|
||||
const z = zahlungen[zahlungsIndex];
|
||||
const zDatum = new Date(z.datum);
|
||||
if (zDatum.getFullYear() === jahr && zDatum.getMonth() === monat) {
|
||||
monatsZahlungen.push(z);
|
||||
zahlungsIndex++;
|
||||
} else if (zDatum > aktuellesDatum) {
|
||||
break;
|
||||
} else {
|
||||
zahlungsIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
const zahlungsSumme = monatsZahlungen.reduce((sum, z) => sum + z.betrag, 0);
|
||||
|
||||
// Abzug: Zinsen zuerst, dann Tilgung
|
||||
let zahlungFuerZinsen = Math.min(zahlungsSumme, monatlicheZinsen);
|
||||
let zahlungFuerTilgung = zahlungsSumme - zahlungFuerZinsen;
|
||||
|
||||
const alteRestschuld = restschuld;
|
||||
restschuld = restschuld + monatlicheZinsen - zahlungsSumme;
|
||||
if (restschuld < 0) restschuld = 0;
|
||||
|
||||
monate.push({
|
||||
datum: new Date(aktuellesDatum),
|
||||
jahr,
|
||||
monat: monat + 1,
|
||||
restschuldVorher: alteRestschuld,
|
||||
zinsen: monatlicheZinsen,
|
||||
zahlungen: monatsZahlungen,
|
||||
zahlungFuerZinsen,
|
||||
zahlungFuerTilgung,
|
||||
restschuldNachher: restschuld,
|
||||
tilgung: alteRestschuld + monatlicheZinsen - restschuld - monatlicheZinsen + zahlungFuerTilgung
|
||||
});
|
||||
|
||||
// Nächster Monat
|
||||
aktuellesDatum.setMonth(aktuellesDatum.getMonth() + 1);
|
||||
}
|
||||
|
||||
return monate;
|
||||
};
|
||||
|
||||
const addKredit = () => {
|
||||
if (!form.name || !form.betrag || !form.startDatum) return;
|
||||
|
||||
const k = {
|
||||
id: Date.now(),
|
||||
name: form.name,
|
||||
betrag: parseFloat(form.betrag),
|
||||
zinssatz: parseFloat(form.zinssatz || 0),
|
||||
startDatum: form.startDatum,
|
||||
notizen: form.notizen,
|
||||
zahlungen: []
|
||||
};
|
||||
|
||||
setKredite([...kredite, k]);
|
||||
setForm({ name: '', betrag: '', zinssatz: '', startDatum: '', notizen: '' });
|
||||
setShowAddForm(false);
|
||||
};
|
||||
|
||||
const updateKredit = (id, updates) => {
|
||||
setKredite(kredite.map(k => k.id === id ? { ...k, ...updates } : k));
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const deleteKredit = (id) => {
|
||||
if (confirm('Kredit wirklich löschen?')) {
|
||||
setKredite(kredite.filter(k => k.id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const addZahlung = (kreditId, betrag, datum, notiz = '') => {
|
||||
setKredite(kredite.map(k => {
|
||||
if (k.id === kreditId) {
|
||||
return {
|
||||
...k,
|
||||
zahlungen: [...(k.zahlungen || []), {
|
||||
id: Date.now(),
|
||||
betrag: parseFloat(betrag),
|
||||
datum,
|
||||
notiz
|
||||
}]
|
||||
};
|
||||
}
|
||||
return k;
|
||||
}));
|
||||
};
|
||||
|
||||
const deleteZahlung = (kreditId, zahlungId) => {
|
||||
setKredite(kredite.map(k => {
|
||||
if (k.id === kreditId) {
|
||||
return {
|
||||
...k,
|
||||
zahlungen: k.zahlungen.filter(z => z.id !== zahlungId)
|
||||
};
|
||||
}
|
||||
return k;
|
||||
}));
|
||||
};
|
||||
|
||||
const totalRestschuld = kredite.reduce((sum, k) => {
|
||||
const monate = berechneMonatlicheWerte(k);
|
||||
const letzterMonat = monate[monate.length - 1];
|
||||
return sum + (letzterMonat ? letzterMonat.restschuldNachher : k.betrag);
|
||||
}, 0);
|
||||
|
||||
const totalKreditsumme = kredite.reduce((sum, k) => sum + k.betrag, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Übersicht */}
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
|
||||
<div>
|
||||
<p className="text-sm text-purple-600">Kredite</p>
|
||||
<p className="text-xl font-bold text-purple-800">{kredite.length}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-purple-600">Gesamtschuld</p>
|
||||
<p className="text-xl font-bold text-purple-800">{totalKreditsumme.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-purple-600">Restschuld</p>
|
||||
<p className="text-xl font-bold text-red-600">{totalRestschuld.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-purple-600">Getilgt</p>
|
||||
<p className="text-xl font-bold text-green-600">{(totalKreditsumme - totalRestschuld).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Neuer Kredit Button */}
|
||||
<button
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
className="w-full bg-purple-600 text-white py-3 rounded-lg hover:bg-purple-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus size={20} />
|
||||
{showAddForm ? 'Abbrechen' : 'Neuen Kredit hinzufügen'}
|
||||
</button>
|
||||
|
||||
{/* Neuer Kredit Formular */}
|
||||
{showAddForm && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Neuer Kredit</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="z.B. Autokredit Niki"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kreditsumme (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.betrag}
|
||||
onChange={(e) => setForm({ ...form, betrag: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="7000"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Zinssatz (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.zinssatz}
|
||||
onChange={(e) => setForm({ ...form, zinssatz: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Start (Monat)</label>
|
||||
<input
|
||||
type="month"
|
||||
value={form.startDatum}
|
||||
onChange={(e) => setForm({ ...form, startDatum: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notizen</label>
|
||||
<textarea
|
||||
value={form.notizen}
|
||||
onChange={(e) => setForm({ ...form, notizen: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="Optional: Details zum Kredit..."
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={addKredit}
|
||||
className="mt-4 w-full bg-purple-600 text-white py-2 rounded hover:bg-purple-700"
|
||||
>
|
||||
💾 Speichern
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kredit-Liste */}
|
||||
{kredite.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{kredite.map(k => {
|
||||
const monate = berechneMonatlicheWerte(k);
|
||||
const aktuellerStand = monate[monate.length - 1];
|
||||
const isExpanded = expandedKredit === k.id;
|
||||
const isEditing = editingId === k.id;
|
||||
|
||||
// Jahresfilter für Verlauf
|
||||
const jahresMonate = monate.filter(m => m.jahr === selectedYear);
|
||||
|
||||
return (
|
||||
<div key={k.id} className="bg-white rounded-lg shadow p-5">
|
||||
{isEditing ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={k.name}
|
||||
onBlur={(e) => updateKredit(k.id, { name: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
placeholder="Name"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
defaultValue={k.betrag}
|
||||
onBlur={(e) => updateKredit(k.id, { betrag: parseFloat(e.target.value) })}
|
||||
className="border rounded px-3 py-2"
|
||||
placeholder="Betrag"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
defaultValue={k.zinssatz}
|
||||
onBlur={(e) => updateKredit(k.id, { zinssatz: parseFloat(e.target.value) })}
|
||||
className="border rounded px-3 py-2"
|
||||
placeholder="Zinssatz"
|
||||
/>
|
||||
<input
|
||||
type="month"
|
||||
defaultValue={k.startDatum}
|
||||
onBlur={(e) => updateKredit(k.id, { startDatum: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEditingId(null)}
|
||||
className="flex items-center gap-2 bg-green-600 text-white px-4 py-2 rounded"
|
||||
>
|
||||
<Save size={16} /> Fertig
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">{k.name}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Start: {new Date(k.startDatum + '-01').toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })} |
|
||||
Zinssatz: {k.zinssatz}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setEditingId(k.id)}
|
||||
className="text-blue-400 hover:text-blue-600"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteKredit(k.id)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 text-sm mb-3">
|
||||
<div>
|
||||
<p className="text-gray-500">Kreditsumme</p>
|
||||
<p className="font-semibold">{k.betrag.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Aktuelle Restschuld</p>
|
||||
<p className="font-semibold text-purple-700">
|
||||
{aktuellerStand ? aktuellerStand.restschuldNachher.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }) : k.betrag.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Monatl. Zinsen (aktuell)</p>
|
||||
<p className="font-semibold text-red-600">
|
||||
{aktuellerStand ? aktuellerStand.zinsen.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 2 }) : '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zahlung hinzufügen */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<h4 className="text-sm font-semibold mb-2">➕ Zahlung hinzufügen</h4>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<input
|
||||
type="date"
|
||||
id={`zahlung-datum-${k.id}`}
|
||||
defaultValue={new Date().toISOString().split('T')[0]}
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
id={`zahlung-betrag-${k.id}`}
|
||||
placeholder="Betrag"
|
||||
className="border rounded px-2 py-1 text-sm w-24"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id={`zahlung-notiz-${k.id}`}
|
||||
placeholder="Notiz (optional)"
|
||||
className="border rounded px-2 py-1 text-sm flex-1 min-w-[120px]"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const datum = document.getElementById(`zahlung-datum-${k.id}`).value;
|
||||
const betrag = document.getElementById(`zahlung-betrag-${k.id}`).value;
|
||||
const notiz = document.getElementById(`zahlung-notiz-${k.id}`).value;
|
||||
if (betrag && datum) {
|
||||
addZahlung(k.id, betrag, datum, notiz);
|
||||
document.getElementById(`zahlung-betrag-${k.id}`).value = '';
|
||||
document.getElementById(`zahlung-notiz-${k.id}`).value = '';
|
||||
}
|
||||
}}
|
||||
className="bg-blue-500 text-white px-3 py-1 rounded text-sm"
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Zahlungsliste */}
|
||||
{(k.zahlungen || []).length > 0 && (
|
||||
<div className="mt-3 space-y-1">
|
||||
{k.zahlungen.sort((a, b) => new Date(b.datum) - new Date(a.datum)).map(z => (
|
||||
<div key={z.id} className="flex justify-between items-center text-sm px-3 py-2 bg-blue-50 rounded">
|
||||
<span>{z.datum} {z.notiz && `- ${z.notiz}`}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-green-700">
|
||||
-{z.betrag.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => deleteZahlung(k.id, z.id)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Monatlicher Verlauf (aufklappbar) */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => setExpandedKredit(isExpanded ? null : k.id)}
|
||||
className="flex items-center gap-2 text-purple-600 hover:text-purple-800 font-medium"
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||
{isExpanded ? 'Verlauf ausblenden' : 'Monatlicher Verlauf anzeigen'}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4">
|
||||
{/* Jahresauswahl */}
|
||||
<div className="mb-4">
|
||||
<label className="text-sm text-gray-600 mr-2">Jahr:</label>
|
||||
<select
|
||||
value={selectedYear}
|
||||
onChange={(e) => setSelectedYear(parseInt(e.target.value))}
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
>
|
||||
{[...new Set(monate.map(m => m.jahr))].sort().map(j => (
|
||||
<option key={j} value={j}>{j}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Verlaufstabelle */}
|
||||
{jahresMonate.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left py-2 px-2">Monat</th>
|
||||
<th className="text-right py-2 px-2">Restschuld (vorher)</th>
|
||||
<th className="text-right py-2 px-2">Zinsen</th>
|
||||
<th className="text-right py-2 px-2">Zahlungen</th>
|
||||
<th className="text-right py-2 px-2">Tilgung</th>
|
||||
<th className="text-right py-2 px-2">Restschuld (nachher)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jahresMonate.map((m, idx) => (
|
||||
<tr key={idx} className="border-b border-gray-100">
|
||||
<td className="py-2 px-2">
|
||||
{m.jahr}-{String(m.monat).padStart(2, '0')}
|
||||
</td>
|
||||
<td className="text-right py-2 px-2">
|
||||
{m.restschuldVorher.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className="text-right py-2 px-2 text-red-600">
|
||||
+{m.zinsen.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className="text-right py-2 px-2 text-green-600">
|
||||
{m.zahlungen.length > 0 ? (
|
||||
<span title={m.zahlungen.map(z => `${z.datum}: ${z.betrag}€`).join('\n')}>
|
||||
-{m.zahlungen.reduce((s, z) => s + z.betrag, 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</span>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="text-right py-2 px-2 text-blue-600">
|
||||
{m.zahlungFuerTilgung > 0 ? `-${m.zahlungFuerTilgung.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 2 })}` : '-'}
|
||||
</td>
|
||||
<td className="text-right py-2 px-2 font-semibold">
|
||||
{m.restschuldNachher.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 2 })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-4">Keine Daten für {selectedYear}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center text-gray-500">
|
||||
Noch keine Kredite erfasst. Füge deinen ersten Kredit hinzu.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Home, Plus, Trash2, Edit2, Save, X, Calculator, FileDown, AlertCircle } from 'lucide-react';
|
||||
import { nebenkostenAPI } from '../api';
|
||||
|
||||
export default function Nebenkosten() {
|
||||
const [nebenkosten, setNebenkosten] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
jahr: new Date().getFullYear(),
|
||||
wohnung: '',
|
||||
mieter: '',
|
||||
kaltmiete: '',
|
||||
nebenkosten: '',
|
||||
heizkosten: '',
|
||||
wasser: '',
|
||||
muell: '',
|
||||
versicherung: '',
|
||||
sonstiges: ''
|
||||
});
|
||||
|
||||
// Lade Nebenkosten beim Start
|
||||
useEffect(() => {
|
||||
loadNebenkosten();
|
||||
}, []);
|
||||
|
||||
const loadNebenkosten = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await nebenkostenAPI.getAll();
|
||||
setNebenkosten(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden: ' + err.message);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addNebenkosten = async () => {
|
||||
if (!form.jahr || !form.wohnung) return;
|
||||
|
||||
try {
|
||||
const data = {
|
||||
jahr: parseInt(form.jahr),
|
||||
wohnung: form.wohnung,
|
||||
mieter: form.mieter,
|
||||
kaltmiete: parseFloat(form.kaltmiete) || 0,
|
||||
nebenkosten: parseFloat(form.nebenkosten) || 0,
|
||||
heizkosten: parseFloat(form.heizkosten) || 0,
|
||||
wasser: parseFloat(form.wasser) || 0,
|
||||
muell: parseFloat(form.muell) || 0,
|
||||
versicherung: parseFloat(form.versicherung) || 0,
|
||||
sonstiges: parseFloat(form.sonstiges) || 0
|
||||
};
|
||||
|
||||
await nebenkostenAPI.create(data);
|
||||
await loadNebenkosten();
|
||||
|
||||
setForm({
|
||||
jahr: new Date().getFullYear(),
|
||||
wohnung: '',
|
||||
mieter: '',
|
||||
kaltmiete: '',
|
||||
nebenkosten: '',
|
||||
heizkosten: '',
|
||||
wasser: '',
|
||||
muell: '',
|
||||
versicherung: '',
|
||||
sonstiges: ''
|
||||
});
|
||||
setShowAddForm(false);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Speichern: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const updateNebenkosten = async (id, updates) => {
|
||||
try {
|
||||
const nk = nebenkosten.find(n => n.id === id);
|
||||
const data = {
|
||||
jahr: updates.jahr !== undefined ? parseInt(updates.jahr) : nk.jahr,
|
||||
wohnung: updates.wohnung || nk.wohnung,
|
||||
mieter: updates.mieter !== undefined ? updates.mieter : nk.mieter,
|
||||
kaltmiete: updates.kaltmiete !== undefined ? parseFloat(updates.kaltmiete) : nk.kaltmiete,
|
||||
nebenkosten: updates.nebenkosten !== undefined ? parseFloat(updates.nebenkosten) : nk.nebenkosten,
|
||||
heizkosten: updates.heizkosten !== undefined ? parseFloat(updates.heizkosten) : nk.heizkosten,
|
||||
wasser: updates.wasser !== undefined ? parseFloat(updates.wasser) : nk.wasser,
|
||||
muell: updates.muell !== undefined ? parseFloat(updates.muell) : nk.muell,
|
||||
versicherung: updates.versicherung !== undefined ? parseFloat(updates.versicherung) : nk.versicherung,
|
||||
sonstiges: updates.sonstiges !== undefined ? parseFloat(updates.sonstiges) : nk.sonstiges
|
||||
};
|
||||
|
||||
await nebenkostenAPI.update(id, data);
|
||||
await loadNebenkosten();
|
||||
setEditingId(null);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Aktualisieren: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteNebenkosten = async (id) => {
|
||||
if (confirm('Position wirklich löschen?')) {
|
||||
try {
|
||||
await nebenkostenAPI.delete(id);
|
||||
await loadNebenkosten();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Löschen: ' + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const jahre = [...new Set(nebenkosten.map(nk => nk.jahr))].sort((a, b) => b - a);
|
||||
|
||||
const gesamtNebenkosten = (nk) => {
|
||||
return (parseFloat(nk.nebenkosten) || 0) +
|
||||
(parseFloat(nk.heizkosten) || 0) +
|
||||
(parseFloat(nk.wasser) || 0) +
|
||||
(parseFloat(nk.muell) || 0) +
|
||||
(parseFloat(nk.versicherung) || 0) +
|
||||
(parseFloat(nk.sonstiges) || 0);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Lade Nebenkosten...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-2 text-red-700">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-auto">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Übersicht pro Jahr */}
|
||||
{jahre.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{jahre.map(jahr => {
|
||||
const jahresDaten = nebenkosten.filter(nk => nk.jahr === jahr);
|
||||
const gesamtKosten = jahresDaten.reduce((sum, nk) => sum + gesamtNebenkosten(nk), 0);
|
||||
const gesamtKaltmiete = jahresDaten.reduce((sum, nk) => sum + (parseFloat(nk.kaltmiete) || 0), 0);
|
||||
|
||||
return (
|
||||
<div key={jahr} className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<p className="text-sm font-medium">{jahr} – {jahresDaten.length} Einträge</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-600">Kaltmieten:</p>
|
||||
<p className="text-lg font-semibold">{gesamtKaltmiete.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-600">Nebenkosten:</p>
|
||||
<p className="text-lg font-semibold text-red-600">{gesamtKosten.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Neuer Eintrag Button */}
|
||||
<button
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
className="w-full bg-green-600 text-white py-3 rounded-lg hover:bg-green-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus size={20} />
|
||||
{showAddForm ? 'Abbrechen' : 'Neue Nebenkosten-Position'}
|
||||
</button>
|
||||
|
||||
{/* Neuer Eintrag Formular */}
|
||||
{showAddForm && (
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-6">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Home size={20} />
|
||||
Neue Nebenkosten-Position
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Jahr</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.jahr}
|
||||
onChange={(e) => setForm({ ...form, jahr: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Wohnung</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.wohnung}
|
||||
onChange={(e) => setForm({ ...form, wohnung: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="z.B. Wilster Wohnung 1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Mieter</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.mieter}
|
||||
onChange={(e) => setForm({ ...form, mieter: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="Name des Mieters"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kaltmiete (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.kaltmiete}
|
||||
onChange={(e) => setForm({ ...form, kaltmiete: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nebenkosten (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.nebenkosten}
|
||||
onChange={(e) => setForm({ ...form, nebenkosten: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Heizkosten (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.heizkosten}
|
||||
onChange={(e) => setForm({ ...form, heizkosten: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Wasser (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.wasser}
|
||||
onChange={(e) => setForm({ ...form, wasser: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Müll (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.muell}
|
||||
onChange={(e) => setForm({ ...form, muell: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Versicherung (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.versicherung}
|
||||
onChange={(e) => setForm({ ...form, versicherung: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Sonstiges (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.sonstiges}
|
||||
onChange={(e) => setForm({ ...form, sonstiges: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={addNebenkosten}
|
||||
className="w-full bg-green-600 text-white py-3 rounded-lg hover:bg-green-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Save size={20} />
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste */}
|
||||
{nebenkosten.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{nebenkosten
|
||||
.sort((a, b) => b.jahr - a.jahr)
|
||||
.map(nk => {
|
||||
const isEditing = editingId === nk.id;
|
||||
const gesamt = gesamtNebenkosten(nk);
|
||||
|
||||
return (
|
||||
<div key={nk.id} className="bg-white rounded-lg shadow p-5">
|
||||
{isEditing ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={nk.wohnung}
|
||||
onBlur={(e) => updateNebenkosten(nk.id, { wohnung: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
placeholder="Wohnung"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={nk.mieter}
|
||||
onBlur={(e) => updateNebenkosten(nk.id, { mieter: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
placeholder="Mieter"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
defaultValue={nk.kaltmiete}
|
||||
onBlur={(e) => updateNebenkosten(nk.id, { kaltmiete: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
placeholder="Kaltmiete"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
defaultValue={nk.nebenkosten}
|
||||
onBlur={(e) => updateNebenkosten(nk.id, { nebenkosten: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
placeholder="Nebenkosten"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEditingId(null)}
|
||||
className="bg-green-600 text-white px-4 py-2 rounded flex items-center gap-2"
|
||||
>
|
||||
<Save size={16} /> Fertig
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">{nk.wohnung}</h3>
|
||||
<p className="text-sm text-gray-600">{nk.mieter}</p>
|
||||
<p className="text-sm text-gray-500">Jahr: {nk.jahr}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setEditingId(nk.id)}
|
||||
className="text-blue-400 hover:text-blue-600"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteNebenkosten(nk.id)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-500">Kaltmiete</p>
|
||||
<p className="font-semibold">{(nk.kaltmiete || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Nebenkosten</p>
|
||||
<p className="font-semibold">{(nk.nebenkosten || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Heizkosten</p>
|
||||
<p className="font-semibold">{(nk.heizkosten || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Gesamt</p>
|
||||
<p className="font-bold text-red-600">{gesamt.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center text-gray-500">
|
||||
Noch keine Nebenkosten erfasst.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
import { useState } from 'react';
|
||||
import { Home, Plus, Trash2, Edit2, Save, X, Calculator, Building, Users, FileDown } from 'lucide-react';
|
||||
|
||||
// NEUE STRUKTUR:
|
||||
// Objekte -> Jahre -> Positionen + Mieter
|
||||
|
||||
export default function NebenkostenV2({
|
||||
objekte, setObjekte,
|
||||
nebenkostenJahre, setNebenkostenJahre,
|
||||
mieter, setMieter
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState('objekte'); // 'objekte', 'kosten', 'mieter', 'abrechnung'
|
||||
const [selectedObjekt, setSelectedObjekt] = useState(null);
|
||||
const [selectedJahr, setSelectedJahr] = useState(new Date().getFullYear());
|
||||
|
||||
// Form States
|
||||
const [showObjektForm, setShowObjektForm] = useState(false);
|
||||
const [showKostenForm, setShowKostenForm] = useState(false);
|
||||
const [showMieterForm, setShowMieterForm] = useState(false);
|
||||
|
||||
const [objektForm, setObjektForm] = useState({
|
||||
name: '',
|
||||
strasse: '',
|
||||
hausnummer: '',
|
||||
plz: '',
|
||||
ort: '',
|
||||
einheiten: 1,
|
||||
wohnflaeche: 0
|
||||
});
|
||||
|
||||
const [kostenForm, setKostenForm] = useState({
|
||||
position: '',
|
||||
betrag: '',
|
||||
splitTyp: '30',
|
||||
gesamtbetrag: '',
|
||||
anteileGesamt: '',
|
||||
meineAnteile: ''
|
||||
});
|
||||
|
||||
const [mieterForm, setMieterForm] = useState({
|
||||
vorname: '',
|
||||
nachname: '',
|
||||
strasse: '',
|
||||
hausnummer: '',
|
||||
plz: '',
|
||||
ort: '',
|
||||
einzugDatum: '',
|
||||
auszugDatum: '',
|
||||
vorauszahlungMonatlich: ''
|
||||
});
|
||||
|
||||
// Hilfsfunktionen
|
||||
const addObjekt = () => {
|
||||
if (!objektForm.name) return;
|
||||
const newObjekt = {
|
||||
id: Date.now(),
|
||||
...objektForm
|
||||
};
|
||||
setObjekte([...objekte, newObjekt]);
|
||||
setObjektForm({
|
||||
name: '', strasse: '', hausnummer: '', plz: '', ort: '',
|
||||
einheiten: 1, wohnflaeche: 0
|
||||
});
|
||||
setShowObjektForm(false);
|
||||
};
|
||||
|
||||
const addKostenPosition = () => {
|
||||
if (!kostenForm.position || !kostenForm.betrag || !selectedObjekt) return;
|
||||
|
||||
const jahrKey = `${selectedObjekt.id}-${selectedJahr}`;
|
||||
const existing = nebenkostenJahre[jahrKey] || { positionen: [], mieter: [] };
|
||||
|
||||
const neuePosition = {
|
||||
id: Date.now(),
|
||||
...kostenForm,
|
||||
betrag: parseFloat(kostenForm.betrag),
|
||||
jahr: selectedJahr,
|
||||
objektId: selectedObjekt.id
|
||||
};
|
||||
|
||||
setNebenkostenJahre({
|
||||
...nebenkostenJahre,
|
||||
[jahrKey]: {
|
||||
...existing,
|
||||
positionen: [...existing.positionen, neuePosition]
|
||||
}
|
||||
});
|
||||
|
||||
setKostenForm({
|
||||
position: '', betrag: '', splitTyp: '30',
|
||||
gesamtbetrag: '', anteileGesamt: '', meineAnteile: ''
|
||||
});
|
||||
setShowKostenForm(false);
|
||||
};
|
||||
|
||||
const addMieter = () => {
|
||||
if (!mieterForm.vorname || !mieterForm.nachname || !selectedObjekt) return;
|
||||
|
||||
const jahrKey = `${selectedObjekt.id}-${selectedJahr}`;
|
||||
const existing = nebenkostenJahre[jahrKey] || { positionen: [], mieter: [] };
|
||||
|
||||
const neuerMieter = {
|
||||
id: Date.now(),
|
||||
...mieterForm,
|
||||
vorauszahlungMonatlich: parseFloat(mieterForm.vorauszahlungMonatlich || 0),
|
||||
jahr: selectedJahr,
|
||||
objektId: selectedObjekt.id
|
||||
};
|
||||
|
||||
setNebenkostenJahre({
|
||||
...nebenkostenJahre,
|
||||
[jahrKey]: {
|
||||
...existing,
|
||||
mieter: [...existing.mieter, neuerMieter]
|
||||
}
|
||||
});
|
||||
|
||||
setMieterForm({
|
||||
vorname: '', nachname: '', strasse: '', hausnummer: '',
|
||||
plz: '', ort: '', einzugDatum: '', auszugDatum: '', vorauszahlungMonatlich: ''
|
||||
});
|
||||
setShowMieterForm(false);
|
||||
};
|
||||
|
||||
// === OBJEKT BEARBEITEN / LÖSCHEN ===
|
||||
const [editingObjekt, setEditingObjekt] = useState(null);
|
||||
|
||||
const startEditObjekt = (objekt) => {
|
||||
setEditingObjekt(objekt);
|
||||
setObjektForm({
|
||||
name: objekt.name || '',
|
||||
strasse: objekt.strasse || '',
|
||||
hausnummer: objekt.hausnummer || '',
|
||||
plz: objekt.plz || '',
|
||||
ort: objekt.ort || '',
|
||||
einheiten: objekt.einheiten || 1,
|
||||
wohnflaeche: objekt.wohnflaeche || 0
|
||||
});
|
||||
setShowObjektForm(true);
|
||||
};
|
||||
|
||||
const updateObjekt = () => {
|
||||
if (!editingObjekt || !objektForm.name) return;
|
||||
setObjekte(objekte.map(o => o.id === editingObjekt.id ? { ...objektForm, id: o.id } : o));
|
||||
setEditingObjekt(null);
|
||||
setObjektForm({ name: '', strasse: '', hausnummer: '', plz: '', ort: '', einheiten: 1, wohnflaeche: 0 });
|
||||
setShowObjektForm(false);
|
||||
};
|
||||
|
||||
const deleteObjekt = (id) => {
|
||||
if (confirm('Objekt wirklich löschen? Alle zugehörigen Daten werden entfernt.')) {
|
||||
setObjekte(objekte.filter(o => o.id !== id));
|
||||
if (selectedObjekt?.id === id) {
|
||||
setSelectedObjekt(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const berechneTage = (einzg, auszg) => {
|
||||
if (!einzg || !auszg) return 365;
|
||||
const start = new Date(einzg);
|
||||
const end = new Date(auszug);
|
||||
return Math.ceil(Math.abs(end - start) / (1000 * 60 * 60 * 24));
|
||||
};
|
||||
|
||||
const berechneMieterAnteil = (position, mieterData) => {
|
||||
const tage = berechneTage(mieterData.einzugDatum, mieterData.auszugDatum);
|
||||
const tagesFaktor = tage / 365;
|
||||
|
||||
let anteil = 0;
|
||||
if (position.splitTyp === 'eigentuemer' && position.gesamtbetrag) {
|
||||
const anteilProzent = parseFloat(position.meineAnteile) / parseFloat(position.anteileGesamt);
|
||||
anteil = parseFloat(position.gesamtbetrag) * anteilProzent * tagesFaktor;
|
||||
} else {
|
||||
const splitFaktor = parseInt(position.splitTyp) / 100;
|
||||
anteil = position.betrag * splitFaktor * tagesFaktor;
|
||||
}
|
||||
|
||||
return anteil;
|
||||
};
|
||||
|
||||
// UI
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b border-gray-200 pb-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('objekte')}
|
||||
className={`px-4 py-2 rounded-t-lg font-medium ${
|
||||
activeTab === 'objekte' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<Building className="inline w-4 h-4 mr-1" />
|
||||
Objekte
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('kosten')}
|
||||
className={`px-4 py-2 rounded-t-lg font-medium ${
|
||||
activeTab === 'kosten' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
💰 Kosten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('mieter')}
|
||||
className={`px-4 py-2 rounded-t-lg font-medium ${
|
||||
activeTab === 'mieter' ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<Users className="inline w-4 h-4 mr-1" />
|
||||
Mieter
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('abrechnung')}
|
||||
className={`px-4 py-2 rounded-t-lg font-medium ${
|
||||
activeTab === 'abrechnung' ? 'bg-orange-600 text-white' : 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<FileDown className="inline w-4 h-4 mr-1" />
|
||||
Abrechnung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* OBJEKTE TAB */}
|
||||
{activeTab === 'objekte' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Building />
|
||||
Meine Objekte
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowObjektForm(!showObjektForm)}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Plus size={18} />
|
||||
Neues Objekt
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showObjektForm && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="font-semibold mb-4">{editingObjekt ? '✏️ Objekt bearbeiten' : '➕ Neues Objekt'}</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name (z.B. Musterstraße 7)"
|
||||
value={objektForm.name}
|
||||
onChange={(e) => setObjektForm({ ...objektForm, name: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Straße"
|
||||
value={objektForm.strasse}
|
||||
onChange={(e) => setObjektForm({ ...objektForm, strasse: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Hausnummer"
|
||||
value={objektForm.hausnummer}
|
||||
onChange={(e) => setObjektForm({ ...objektForm, hausnummer: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="PLZ"
|
||||
value={objektForm.plz}
|
||||
onChange={(e) => setObjektForm({ ...objektForm, plz: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ort"
|
||||
value={objektForm.ort}
|
||||
onChange={(e) => setObjektForm({ ...objektForm, ort: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Wohnfläche (m²)"
|
||||
value={objektForm.wohnflaeche}
|
||||
onChange={(e) => setObjektForm({ ...objektForm, wohnflaeche: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={editingObjekt ? updateObjekt : addObjekt}
|
||||
className="mt-4 w-full bg-blue-600 text-white py-2 rounded"
|
||||
>
|
||||
{editingObjekt ? '💾 Änderungen speichern' : '💾 Speichern'}
|
||||
</button>
|
||||
{editingObjekt && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingObjekt(null);
|
||||
setObjektForm({ name: '', strasse: '', hausnummer: '', plz: '', ort: '', einheiten: 1, wohnflaeche: 0 });
|
||||
setShowObjektForm(false);
|
||||
}}
|
||||
className="mt-2 w-full bg-gray-300 text-gray-700 py-2 rounded"
|
||||
>
|
||||
❌ Abbrechen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{objekte.map(objekt => (
|
||||
<div
|
||||
key={objekt.id}
|
||||
className={`bg-white rounded-lg shadow p-4 hover:shadow-lg transition-shadow ${
|
||||
selectedObjekt?.id === objekt.id ? 'ring-2 ring-blue-500' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div
|
||||
onClick={() => {
|
||||
setSelectedObjekt(objekt);
|
||||
setActiveTab('kosten');
|
||||
}}
|
||||
className="flex-1 cursor-pointer"
|
||||
>
|
||||
<h3 className="font-semibold text-lg">{objekt.name}</h3>
|
||||
<p className="text-gray-600 text-sm">
|
||||
{objekt.strasse} {objekt.hausnummer}, {objekt.plz} {objekt.ort}
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm mt-2">
|
||||
{objekt.wohnflaeche} m² Wohnfläche
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEditObjekt(objekt);
|
||||
}}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 rounded"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteObjekt(objekt.id);
|
||||
}}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KOSTEN TAB */}
|
||||
{activeTab === 'kosten' && (
|
||||
<div className="space-y-4">
|
||||
{!selectedObjekt ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
Bitte wähle zuerst ein Objekt im Tab "Objekte" aus.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">💰 Kosten für {selectedObjekt.name}</h2>
|
||||
<div className="flex gap-2 mt-2">
|
||||
{[2024, 2025, 2026, 2027].map(jahr => (
|
||||
<button
|
||||
key={jahr}
|
||||
onClick={() => setSelectedJahr(jahr)}
|
||||
className={`px-3 py-1 rounded ${
|
||||
selectedJahr === jahr ? 'bg-green-600 text-white' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{jahr}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowKostenForm(!showKostenForm)}
|
||||
className="bg-green-600 text-white px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Plus size={18} />
|
||||
Neue Position
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showKostenForm && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="font-semibold mb-4">Neue Kostenposition für {selectedJahr}</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Position (z.B. Heizung)"
|
||||
value={kostenForm.position}
|
||||
onChange={(e) => setKostenForm({ ...kostenForm, position: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="Gesamtkosten (€)"
|
||||
value={kostenForm.betrag}
|
||||
onChange={(e) => setKostenForm({ ...kostenForm, betrag: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
<select
|
||||
value={kostenForm.splitTyp}
|
||||
onChange={(e) => setKostenForm({ ...kostenForm, splitTyp: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
>
|
||||
<option value="30">30% (Mietfläche)</option>
|
||||
<option value="70">70% (Eigentumsanteil)</option>
|
||||
<option value="100">100% (Voll)</option>
|
||||
<option value="eigentuemer">Eigentümer (nach Anteilen)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={addKostenPosition}
|
||||
className="mt-4 w-full bg-green-600 text-white py-2 rounded"
|
||||
>
|
||||
💾 Speichern
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste der Positionen */}
|
||||
{(() => {
|
||||
const jahrKey = `${selectedObjekt.id}-${selectedJahr}`;
|
||||
const data = nebenkostenJahre[jahrKey];
|
||||
if (!data || data.positionen.length === 0) {
|
||||
return <div className="text-gray-500 text-center py-8">Noch keine Kosten erfasst.</div>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{data.positionen.map(pos => (
|
||||
<div key={pos.id} className="bg-white rounded-lg shadow p-4 flex justify-between">
|
||||
<div>
|
||||
<p className="font-semibold">{pos.position}</p>
|
||||
<p className="text-sm text-gray-600">{pos.splitTyp}% Aufteilung</p>
|
||||
</div>
|
||||
<p className="font-bold text-green-700">
|
||||
{parseFloat(pos.betrag).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MIETER TAB */}
|
||||
{activeTab === 'mieter' && (
|
||||
<div className="space-y-4">
|
||||
{!selectedObjekt ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
Bitte wähle zuerst ein Objekt im Tab "Objekte" aus.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Users />
|
||||
Mieter für {selectedObjekt.name}
|
||||
</h2>
|
||||
<p className="text-gray-600">Jahr: {selectedJahr}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowMieterForm(!showMieterForm)}
|
||||
className="bg-purple-600 text-white px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Plus size={18} />
|
||||
Neuer Mieter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showMieterForm && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="font-semibold mb-4">Neuer Mieter für {selectedJahr}</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Vorname"
|
||||
value={mieterForm.vorname}
|
||||
onChange={(e) => setMieterForm({ ...mieterForm, vorname: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nachname"
|
||||
value={mieterForm.nachname}
|
||||
onChange={(e) => setMieterForm({ ...mieterForm, nachname: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
placeholder="Einzug"
|
||||
value={mieterForm.einzugDatum}
|
||||
onChange={(e) => setMieterForm({ ...mieterForm, einzugDatum: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
placeholder="Auszug"
|
||||
value={mieterForm.auszugDatum}
|
||||
onChange={(e) => setMieterForm({ ...mieterForm, auszugDatum: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="Vorauszahlung/Monat (€)"
|
||||
value={mieterForm.vorauszahlungMonatlich}
|
||||
onChange={(e) => setMieterForm({ ...mieterForm, vorauszahlungMonatlich: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={addMieter}
|
||||
className="mt-4 w-full bg-purple-600 text-white py-2 rounded"
|
||||
>
|
||||
💾 Speichern
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste der Mieter */}
|
||||
{(() => {
|
||||
const jahrKey = `${selectedObjekt.id}-${selectedJahr}`;
|
||||
const data = nebenkostenJahre[jahrKey];
|
||||
if (!data || data.mieter.length === 0) {
|
||||
return <div className="text-gray-500 text-center py-8">Noch keine Mieter erfasst.</div>;
|
||||
}
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{data.mieter.map(m => (
|
||||
<div key={m.id} className="bg-white rounded-lg shadow p-4">
|
||||
<p className="font-semibold">{m.vorname} {m.nachname}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{m.einzugDatum} - {m.auszugDatum || 'laufend'}
|
||||
</p>
|
||||
<p className="text-sm text-purple-600 mt-2">
|
||||
Vorauszahlung: {(m.vorauszahlungMonatlich || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}/Monat
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ABRECHNUNG TAB */}
|
||||
{activeTab === 'abrechnung' && (
|
||||
<div className="space-y-4">
|
||||
{!selectedObjekt ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
Bitte wähle zuerst ein Objekt und erfasse Kosten sowie Mieter.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-xl font-semibold">📋 Abrechnung für {selectedObjekt.name}</h2>
|
||||
|
||||
{(() => {
|
||||
const jahrKey = `${selectedObjekt.id}-${selectedJahr}`;
|
||||
const data = nebenkostenJahre[jahrKey];
|
||||
|
||||
if (!data || data.mieter.length === 0) {
|
||||
return <div className="text-gray-500">Keine Mieter für dieses Jahr erfasst.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{data.mieter.map(mieter => {
|
||||
const tage = berechneTage(mieter.einzugDatum, mieter.auszugDatum);
|
||||
const kostenSumme = data.positionen.reduce((sum, pos) => {
|
||||
return sum + berechneMieterAnteil(pos, mieter);
|
||||
}, 0);
|
||||
const vorauszahlung = (mieter.vorauszahlungMonatlich || 0) * 12;
|
||||
const differenz = vorauszahlung - kostenSumme;
|
||||
|
||||
return (
|
||||
<div key={mieter.id} className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="font-semibold text-lg mb-4">
|
||||
{mieter.vorname} {mieter.nachname}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Zeitraum: {mieter.einzugDatum} - {mieter.auszugDatum || '31.12.'+selectedJahr} ({tage} Tage)
|
||||
</p>
|
||||
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left p-2">Position</th>
|
||||
<th className="text-right p-2">Anteil</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.positionen.map(pos => (
|
||||
<tr key={pos.id} className="border-b">
|
||||
<td className="p-2">{pos.position}</td>
|
||||
<td className="text-right p-2">
|
||||
{berechneMieterAnteil(pos, mieter).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="bg-gray-100 font-semibold">
|
||||
<tr>
|
||||
<td className="p-2">Gesamtkosten</td>
|
||||
<td className="text-right p-2">{kostenSumme.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-2">Vorauszahlung</td>
|
||||
<td className="text-right p-2">{vorauszahlung.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr className={differenz >= 0 ? 'text-green-600' : 'text-red-600'}>
|
||||
<td className="p-2">{differenz >= 0 ? 'Rückzahlung' : 'Nachzahlung'}</td>
|
||||
<td className="text-right p-2">{Math.abs(differenz).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<button className="mt-4 bg-blue-600 text-white px-4 py-2 rounded flex items-center gap-2">
|
||||
<FileDown size={16} />
|
||||
PDF für {mieter.nachname}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Wallet, Plus, Trash2, Save, TrendingUp, Calendar, DollarSign, AlertCircle, X, ShoppingCart, Car, Utensils, Zap, Home, Heart, GraduationCap, Plane, MoreHorizontal, Shield, CreditCard, Repeat, FileText, RefreshCw } from 'lucide-react';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
const KATEGORIEN = [
|
||||
{ id: 'essen', label: 'Essen & Trinken', icon: Utensils, color: 'text-orange-600', bg: 'bg-orange-100' },
|
||||
{ id: 'mobilitaet', label: 'Mobilität', icon: Car, color: 'text-blue-600', bg: 'bg-blue-100' },
|
||||
{ id: 'wohnen', label: 'Wohnen', icon: Home, color: 'text-emerald-600', bg: 'bg-emerald-100' },
|
||||
{ id: 'energie', label: 'Energie', icon: Zap, color: 'text-yellow-600', bg: 'bg-yellow-100' },
|
||||
{ id: 'gesundheit', label: 'Gesundheit', icon: Heart, color: 'text-rose-600', bg: 'bg-rose-100' },
|
||||
{ id: 'bildung', label: 'Bildung', icon: GraduationCap, color: 'text-purple-600', bg: 'bg-purple-100' },
|
||||
{ id: 'freizeit', label: 'Freizeit & Reisen', icon: Plane, color: 'text-cyan-600', bg: 'bg-cyan-100' },
|
||||
{ id: 'shopping', label: 'Shopping', icon: ShoppingCart, color: 'text-pink-600', bg: 'bg-pink-100' },
|
||||
// NEU: Wiederkehrende Kosten
|
||||
{ id: 'versicherung', label: 'Versicherungen', icon: Shield, color: 'text-indigo-600', bg: 'bg-indigo-100' },
|
||||
{ id: 'abos', label: 'Abos & Verträge', icon: CreditCard, color: 'text-teal-600', bg: 'bg-teal-100' },
|
||||
{ id: 'fixkosten', label: 'Fixkosten', icon: FileText, color: 'text-slate-600', bg: 'bg-slate-100' },
|
||||
{ id: 'sonstiges', label: 'Sonstiges', icon: MoreHorizontal, color: 'text-gray-600', bg: 'bg-gray-100' },
|
||||
];
|
||||
|
||||
const MONATE = [
|
||||
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
|
||||
];
|
||||
|
||||
const WIEDERKEHRENDE_TYPEN = [
|
||||
{ id: 'monatlich', label: 'Monatlich' },
|
||||
{ id: 'quartalsweise', label: 'Quartalsweise' },
|
||||
{ id: 'halbjaehrlich', label: 'Halbjährlich' },
|
||||
{ id: 'jaehrlich', label: 'Jährlich' },
|
||||
];
|
||||
|
||||
export default function PrivatAusgaben() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [ausgaben, setAusgaben] = useState([]);
|
||||
const [selectedJahr, setSelectedJahr] = useState(2025);
|
||||
const [selectedMonat, setSelectedMonat] = useState(new Date().getMonth());
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
|
||||
const [newAusgabe, setNewAusgabe] = useState({
|
||||
kategorie: 'essen',
|
||||
betrag: '',
|
||||
bezeichnung: '',
|
||||
datum: new Date().toISOString().split('T')[0],
|
||||
// NEU: Einmalig vs Wiederkehrend
|
||||
ist_wiederkehrend: false,
|
||||
wiederkehrend_typ: 'monatlich',
|
||||
wiederkehrend_monat: new Date().getMonth(),
|
||||
wiederkehrend_jahr: new Date().getFullYear()
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadAusgaben();
|
||||
}, [selectedJahr, selectedMonat]);
|
||||
|
||||
const loadAusgaben = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// API Aufruf oder lokale Daten
|
||||
const response = await fetch(`${API_URL}/privat/ausgaben?jahr=${selectedJahr}&monat=${selectedMonat}`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Transformiere Daten für neue Struktur
|
||||
const transformed = data.map(a => ({
|
||||
...a,
|
||||
ist_wiederkehrend: a.ist_wiederkehrend || false,
|
||||
wiederkehrend_typ: a.wiederkehrend_typ || 'monatlich',
|
||||
wiederkehrend_monat: a.wiederkehrend_monat || null,
|
||||
wiederkehrend_jahr: a.wiederkehrend_jahr || null
|
||||
}));
|
||||
setAusgaben(transformed);
|
||||
} else {
|
||||
// Demo-Daten falls API nicht verfügbar
|
||||
setAusgaben([]);
|
||||
}
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.log('API nicht verfügbar, starte mit leerer Liste');
|
||||
setAusgaben([]);
|
||||
setError(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addAusgabe = async () => {
|
||||
if (!newAusgabe.betrag || !newAusgabe.bezeichnung) return;
|
||||
|
||||
const ausgabe = {
|
||||
id: Date.now(),
|
||||
...newAusgabe,
|
||||
betrag: parseFloat(newAusgabe.betrag),
|
||||
jahr: selectedJahr,
|
||||
monat: selectedMonat,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
try {
|
||||
// API Aufruf zum Speichern
|
||||
const response = await fetch(`${API_URL}/privat/ausgaben`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(ausgabe)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadAusgaben();
|
||||
} else {
|
||||
// Lokal hinzufügen falls API nicht verfügbar
|
||||
setAusgaben(prev => [...prev, ausgabe]);
|
||||
}
|
||||
|
||||
setNewAusgabe({
|
||||
kategorie: 'essen',
|
||||
betrag: '',
|
||||
bezeichnung: '',
|
||||
datum: new Date().toISOString().split('T')[0],
|
||||
ist_wiederkehrend: false,
|
||||
wiederkehrend_typ: 'monatlich',
|
||||
wiederkehrend_monat: new Date().getMonth(),
|
||||
wiederkehrend_jahr: new Date().getFullYear()
|
||||
});
|
||||
setShowAddDialog(false);
|
||||
} catch (err) {
|
||||
// Lokal hinzufügen
|
||||
setAusgaben(prev => [...prev, ausgabe]);
|
||||
setShowAddDialog(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAusgabe = async (id) => {
|
||||
if (!confirm('Ausgabe wirklich löschen?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/privat/ausgaben/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadAusgaben();
|
||||
} else {
|
||||
setAusgaben(prev => prev.filter(a => a.id !== id));
|
||||
}
|
||||
} catch (err) {
|
||||
setAusgaben(prev => prev.filter(a => a.id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
// Berechne Summen
|
||||
const monatlicheSumme = ausgaben.reduce((sum, a) => sum + (parseFloat(a.betrag) || 0), 0);
|
||||
|
||||
// Gruppiere nach Kategorie
|
||||
const ausgabenNachKategorie = KATEGORIEN.map(kat => {
|
||||
const katAusgaben = ausgaben.filter(a => a.kategorie === kat.id);
|
||||
const summe = katAusgaben.reduce((sum, a) => sum + (parseFloat(a.betrag) || 0), 0);
|
||||
return { ...kat, ausgaben: katAusgaben, summe };
|
||||
}).filter(k => k.summe > 0 || k.ausgaben.length > 0);
|
||||
|
||||
// Berechne wiederkehrende vs einmalige Kosten
|
||||
const wiederkehrendeSumme = ausgaben
|
||||
.filter(a => a.ist_wiederkehrend)
|
||||
.reduce((sum, a) => sum + (parseFloat(a.betrag) || 0), 0);
|
||||
const einmaligeSumme = ausgaben
|
||||
.filter(a => !a.ist_wiederkehrend)
|
||||
.reduce((sum, a) => sum + (parseFloat(a.betrag) || 0), 0);
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
return (parseFloat(value) || 0).toLocaleString('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
});
|
||||
};
|
||||
|
||||
const formatWiederkehrend = (ausgabe) => {
|
||||
if (!ausgabe.ist_wiederkehrend) return 'Einmalig';
|
||||
const typ = WIEDERKEHRENDE_TYPEN.find(t => t.id === ausgabe.wiederkehrend_typ)?.label || ausgabe.wiederkehrend_typ;
|
||||
return `${typ} (ab ${MONATE[ausgabe.wiederkehrend_monat || 0]} ${ausgabe.wiederkehrend_jahr || ''})`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Lade private Ausgaben...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-emerald-600 to-teal-700 rounded-lg p-6 text-white">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Wallet size={28} />
|
||||
Monatliche Ausgaben
|
||||
</h2>
|
||||
<p className="text-emerald-200 mt-1">Private Kosten - Einmalig & Wiederkehrend</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={selectedJahr}
|
||||
onChange={(e) => setSelectedJahr(parseInt(e.target.value))}
|
||||
className="bg-white/10 border border-white/20 rounded-lg px-3 py-2 text-white"
|
||||
>
|
||||
<option value={2024} className="text-gray-900">2024</option>
|
||||
<option value={2025} className="text-gray-900">2025</option>
|
||||
<option value={2026} className="text-gray-900">2026</option>
|
||||
</select>
|
||||
<select
|
||||
value={selectedMonat}
|
||||
onChange={(e) => setSelectedMonat(parseInt(e.target.value))}
|
||||
className="bg-white/10 border border-white/20 rounded-lg px-3 py-2 text-white"
|
||||
>
|
||||
{MONATE.map((m, idx) => (
|
||||
<option key={idx} value={idx} className="text-gray-900">{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-2 text-red-700">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-auto">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gesamt-Kacheln */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-emerald-500">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-emerald-100 p-2 rounded-lg">
|
||||
<DollarSign className="text-emerald-600" size={24} />
|
||||
</div>
|
||||
<span className="text-gray-600">Gesamtausgaben</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-emerald-700">
|
||||
{formatCurrency(monatlicheSumme)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{MONATE[selectedMonat]} {selectedJahr}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-blue-500">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-blue-100 p-2 rounded-lg">
|
||||
<Repeat className="text-blue-600" size={24} />
|
||||
</div>
|
||||
<span className="text-gray-600">Wiederkehrend</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-blue-700">
|
||||
{formatCurrency(wiederkehrendeSumme)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{ausgaben.filter(a => a.ist_wiederkehrend).length} Positionen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-orange-500">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-orange-100 p-2 rounded-lg">
|
||||
<Calendar className="text-orange-600" size={24} />
|
||||
</div>
|
||||
<span className="text-gray-600">Einmalig</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-orange-700">
|
||||
{formatCurrency(einmaligeSumme)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{ausgaben.filter(a => !a.ist_wiederkehrend).length} Positionen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-purple-500">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-purple-100 p-2 rounded-lg">
|
||||
<TrendingUp className="text-purple-600" size={24} />
|
||||
</div>
|
||||
<span className="text-gray-600">Durchschnitt</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-purple-700">
|
||||
{ausgaben.length > 0 ? formatCurrency(monatlicheSumme / ausgaben.length) : '-'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Pro Ausgabe</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Neue Ausgabe Button */}
|
||||
<button
|
||||
onClick={() => setShowAddDialog(!showAddDialog)}
|
||||
className="w-full bg-emerald-600 text-white py-3 rounded-lg hover:bg-emerald-700 flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
<Plus size={20} />
|
||||
{showAddDialog ? 'Abbrechen' : 'Neue Ausgabe hinzufügen'}
|
||||
</button>
|
||||
|
||||
{/* Add Dialog */}
|
||||
{showAddDialog && (
|
||||
<div className="bg-white rounded-lg shadow p-6 border-2 border-emerald-200">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Wallet className="text-emerald-600" size={20} />
|
||||
Neue Ausgabe
|
||||
</h3>
|
||||
|
||||
{/* Toggle: Einmalig vs Wiederkehrend */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Art der Ausgabe</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setNewAusgabe({ ...newAusgabe, ist_wiederkehrend: false })}
|
||||
className={`flex-1 py-2 px-4 rounded-lg flex items-center justify-center gap-2 transition-colors ${
|
||||
!newAusgabe.ist_wiederkehrend
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Calendar size={18} />
|
||||
Einmalig
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setNewAusgabe({ ...newAusgabe, ist_wiederkehrend: true })}
|
||||
className={`flex-1 py-2 px-4 rounded-lg flex items-center justify-center gap-2 transition-colors ${
|
||||
newAusgabe.ist_wiederkehrend
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
Wiederkehrend
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Bezeichnung</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newAusgabe.bezeichnung}
|
||||
onChange={(e) => setNewAusgabe({ ...newAusgabe, bezeichnung: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder={newAusgabe.ist_wiederkehrend ? "z.B. Hausratversicherung" : "z.B. Wocheneinkauf Edeka"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Betrag (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={newAusgabe.betrag}
|
||||
onChange={(e) => setNewAusgabe({ ...newAusgabe, betrag: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||
<select
|
||||
value={newAusgabe.kategorie}
|
||||
onChange={(e) => setNewAusgabe({ ...newAusgabe, kategorie: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
>
|
||||
<optgroup label="Allgemein">
|
||||
{KATEGORIEN.filter(k => ['essen', 'mobilitaet', 'wohnen', 'energie', 'gesundheit', 'bildung', 'freizeit', 'shopping', 'sonstiges'].includes(k.id)).map(kat => (
|
||||
<option key={kat.id} value={kat.id}>{kat.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{newAusgabe.ist_wiederkehrend && (
|
||||
<optgroup label="Wiederkehrende Kosten">
|
||||
{KATEGORIEN.filter(k => ['versicherung', 'abos', 'fixkosten'].includes(k.id)).map(kat => (
|
||||
<option key={kat.id} value={kat.id}>{kat.label}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Datum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={newAusgabe.datum}
|
||||
onChange={(e) => setNewAusgabe({ ...newAusgabe, datum: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wiederkehrende Optionen */}
|
||||
{newAusgabe.ist_wiederkehrend && (
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 mb-4">
|
||||
<h4 className="text-sm font-semibold text-purple-800 mb-3 flex items-center gap-2">
|
||||
<RefreshCw size={16} />
|
||||
Wiederholungs-Einstellungen
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-purple-700 mb-1">Wiederholung</label>
|
||||
<select
|
||||
value={newAusgabe.wiederkehrend_typ}
|
||||
onChange={(e) => setNewAusgabe({ ...newAusgabe, wiederkehrend_typ: e.target.value })}
|
||||
className="w-full border border-purple-300 rounded-lg px-3 py-2 bg-white"
|
||||
>
|
||||
{WIEDERKEHRENDE_TYPEN.map(typ => (
|
||||
<option key={typ.id} value={typ.id}>{typ.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-purple-700 mb-1">Start-Monat</label>
|
||||
<select
|
||||
value={newAusgabe.wiederkehrend_monat}
|
||||
onChange={(e) => setNewAusgabe({ ...newAusgabe, wiederkehrend_monat: parseInt(e.target.value) })}
|
||||
className="w-full border border-purple-300 rounded-lg px-3 py-2 bg-white"
|
||||
>
|
||||
{MONATE.map((m, idx) => (
|
||||
<option key={idx} value={idx}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-purple-700 mb-1">Start-Jahr</label>
|
||||
<select
|
||||
value={newAusgabe.wiederkehrend_jahr}
|
||||
onChange={(e) => setNewAusgabe({ ...newAusgabe, wiederkehrend_jahr: parseInt(e.target.value) })}
|
||||
className="w-full border border-purple-300 rounded-lg px-3 py-2 bg-white"
|
||||
>
|
||||
<option value={2024}>2024</option>
|
||||
<option value={2025}>2025</option>
|
||||
<option value={2026}>2026</option>
|
||||
<option value={2027}>2027</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={addAusgabe}
|
||||
disabled={!newAusgabe.betrag || !newAusgabe.bezeichnung}
|
||||
className="bg-emerald-600 text-white py-2 px-6 rounded-lg hover:bg-emerald-700 flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<Save size={16} /> Speichern
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ausgaben nach Kategorie */}
|
||||
{ausgabenNachKategorie.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{ausgabenNachKategorie.map(kategorie => {
|
||||
const Icon = kategorie.icon;
|
||||
return (
|
||||
<div key={kategorie.id} className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className={`px-6 py-4 border-b border-gray-100 flex items-center justify-between ${kategorie.bg}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className={kategorie.color} size={24} />
|
||||
<h3 className={`font-semibold ${kategorie.color}`}>{kategorie.label}</h3>
|
||||
</div>
|
||||
<span className={`font-bold ${kategorie.color}`}>
|
||||
{formatCurrency(kategorie.summe)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{kategorie.ausgaben.map(ausgabe => (
|
||||
<div key={ausgabe.id} className="px-6 py-3 flex items-center justify-between hover:bg-gray-50">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-gray-900">{ausgabe.bezeichnung}</p>
|
||||
{ausgabe.ist_wiederkehrend && (
|
||||
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<RefreshCw size={10} />
|
||||
{WIEDERKEHRENDE_TYPEN.find(t => t.id === ausgabe.wiederkehrend_typ)?.label || 'Wdh.'}
|
||||
</span>
|
||||
)}
|
||||
{!ausgabe.ist_wiederkehrend && (
|
||||
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full">
|
||||
Einmalig
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
{new Date(ausgabe.datum).toLocaleDateString('de-DE')}
|
||||
{ausgabe.ist_wiederkehrend && (
|
||||
<span className="text-purple-600 ml-2">
|
||||
(ab {MONATE[ausgabe.wiederkehrend_monat || 0]} {ausgabe.wiederkehrend_jahr || ''})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="font-semibold text-gray-900">
|
||||
{formatCurrency(ausgabe.betrag)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => deleteAusgabe(ausgabe.id)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center">
|
||||
<div className="bg-gray-100 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Wallet className="text-gray-400" size={32} />
|
||||
</div>
|
||||
<p className="text-gray-500">Noch keine Ausgaben in {MONATE[selectedMonat]} {selectedJahr}</p>
|
||||
<p className="text-sm text-gray-400 mt-2">Füge deine erste Ausgabe hinzu!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CreditCard, Plus, Trash2, Save, TrendingUp, Calendar, DollarSign, AlertCircle, X, Building, Car, Home, Wallet, Briefcase } from 'lucide-react';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
const KREDIT_TYPEN = [
|
||||
{ id: 'immobilie', label: 'Immobilienfinanzierung', icon: Building, color: 'text-blue-600', bg: 'bg-blue-100' },
|
||||
{ id: 'kfz', label: 'KFZ-Kredit', icon: Car, color: 'text-emerald-600', bg: 'bg-emerald-100' },
|
||||
{ id: 'privat', label: 'Privater Kredit', icon: Wallet, color: 'text-purple-600', bg: 'bg-purple-100' },
|
||||
{ id: 'gewerbe', label: 'Gewerblicher Kredit', icon: Briefcase, color: 'text-orange-600', bg: 'bg-orange-100' },
|
||||
{ id: 'sonstiges', label: 'Sonstiger Kredit', icon: Home, color: 'text-gray-600', bg: 'bg-gray-100' },
|
||||
];
|
||||
|
||||
const MONATE = [
|
||||
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
|
||||
];
|
||||
|
||||
export default function PrivatKredite() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [kredite, setKredite] = useState([]);
|
||||
const [selectedJahr, setSelectedJahr] = useState(2025);
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
|
||||
const [newKredit, setNewKredit] = useState({
|
||||
name: '',
|
||||
typ: 'privat',
|
||||
kreditgeber: '',
|
||||
darlehensbetrag: '',
|
||||
monatsrate: '',
|
||||
zinssatz: '',
|
||||
restschuld: '',
|
||||
laufzeitBis: ''
|
||||
});
|
||||
|
||||
// Demo-Daten
|
||||
const demoKredite = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Hausbank Kredit',
|
||||
typ: 'privat',
|
||||
kreditgeber: 'Sparkasse Steinburg',
|
||||
darlehensbetrag: 25000,
|
||||
monatsrate: 450,
|
||||
zinssatz: 3.5,
|
||||
restschuld: 18000,
|
||||
laufzeitBis: '2028-06-15'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'KFZ-Finanzierung',
|
||||
typ: 'kfz',
|
||||
kreditgeber: 'Volkswagen Bank',
|
||||
darlehensbetrag: 32000,
|
||||
monatsrate: 520,
|
||||
zinssatz: 2.9,
|
||||
restschuld: 21000,
|
||||
laufzeitBis: '2027-03-20'
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadKredite();
|
||||
}, [selectedJahr]);
|
||||
|
||||
const loadKredite = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${API_URL}/privat/kredite?jahr=${selectedJahr}`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setKredite(data.length > 0 ? data : demoKredite);
|
||||
} else {
|
||||
setKredite(demoKredite);
|
||||
}
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.log('API nicht verfügbar, starte mit Demo-Daten');
|
||||
setKredite(demoKredite);
|
||||
setError(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addKredit = async () => {
|
||||
if (!newKredit.name || !newKredit.monatsrate) return;
|
||||
|
||||
const kredit = {
|
||||
id: Date.now(),
|
||||
...newKredit,
|
||||
darlehensbetrag: parseFloat(newKredit.darlehensbetrag) || 0,
|
||||
monatsrate: parseFloat(newKredit.monatsrate) || 0,
|
||||
zinssatz: parseFloat(newKredit.zinssatz) || 0,
|
||||
restschuld: parseFloat(newKredit.restschuld) || 0,
|
||||
jahr: selectedJahr,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/privat/kredite`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(kredit)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadKredite();
|
||||
} else {
|
||||
setKredite(prev => [...prev, kredit]);
|
||||
}
|
||||
|
||||
setNewKredit({
|
||||
name: '',
|
||||
typ: 'privat',
|
||||
kreditgeber: '',
|
||||
darlehensbetrag: '',
|
||||
monatsrate: '',
|
||||
zinssatz: '',
|
||||
restschuld: '',
|
||||
laufzeitBis: ''
|
||||
});
|
||||
setShowAddDialog(false);
|
||||
} catch (err) {
|
||||
setKredite(prev => [...prev, kredit]);
|
||||
setShowAddDialog(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteKredit = async (id) => {
|
||||
if (!confirm('Kredit wirklich löschen?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/privat/kredite/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadKredite();
|
||||
} else {
|
||||
setKredite(prev => prev.filter(k => k.id !== id));
|
||||
}
|
||||
} catch (err) {
|
||||
setKredite(prev => prev.filter(k => k.id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
// Berechne Summen
|
||||
const gesamtMonatsrate = kredite.reduce((sum, k) => sum + (parseFloat(k.monatsrate) || 0), 0);
|
||||
const gesamtRestschuld = kredite.reduce((sum, k) => sum + (parseFloat(k.restschuld) || 0), 0);
|
||||
const gesamtDarlehensbetrag = kredite.reduce((sum, k) => sum + (parseFloat(k.darlehensbetrag) || 0), 0);
|
||||
|
||||
// Gruppiere nach Typ
|
||||
const krediteNachTyp = KREDIT_TYPEN.map(typ => {
|
||||
const typKredite = kredite.filter(k => k.typ === typ.id);
|
||||
const monatsrateSumme = typKredite.reduce((sum, k) => sum + (parseFloat(k.monatsrate) || 0), 0);
|
||||
return { ...typ, kredite: typKredite, monatsrateSumme };
|
||||
}).filter(t => t.kredite.length > 0);
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
return (parseFloat(value) || 0).toLocaleString('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Lade Kredit-Übersicht...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg p-6 text-white">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<CreditCard size={28} />
|
||||
Kredit-Übersicht
|
||||
</h2>
|
||||
<p className="text-blue-200 mt-1">Alle Raten und Zahlungen im Überblick</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={selectedJahr}
|
||||
onChange={(e) => setSelectedJahr(parseInt(e.target.value))}
|
||||
className="bg-white/10 border border-white/20 rounded-lg px-3 py-2 text-white"
|
||||
>
|
||||
<option value={2024} className="text-gray-900">2024</option>
|
||||
<option value={2025} className="text-gray-900">2025</option>
|
||||
<option value={2026} className="text-gray-900">2026</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-2 text-red-700">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-auto">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-blue-500">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-blue-100 p-2 rounded-lg">
|
||||
<CreditCard className="text-blue-600" size={24} />
|
||||
</div>
|
||||
<span className="text-gray-600">Gesamtrate/Monat</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-blue-700">
|
||||
{formatCurrency(gesamtMonatsrate)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{kredite.length} aktive Kredite
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-emerald-500">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-emerald-100 p-2 rounded-lg">
|
||||
<DollarSign className="text-emerald-600" size={24} />
|
||||
</div>
|
||||
<span className="text-gray-600">Restschuld gesamt</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-emerald-700">
|
||||
{formatCurrency(gesamtRestschuld)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Noch zu tilgen</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 border-l-4 bg-purple-500">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-purple-100 p-2 rounded-lg">
|
||||
<TrendingUp className="text-purple-600" size={24} />
|
||||
</div>
|
||||
<span className="text-gray-600">Ursprünglich</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-purple-700">
|
||||
{formatCurrency(gesamtDarlehensbetrag)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Gesamtdarlehen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Neuer Kredit Button */}
|
||||
<button
|
||||
onClick={() => setShowAddDialog(!showAddDialog)}
|
||||
className="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
<Plus size={20} />
|
||||
{showAddDialog ? 'Abbrechen' : 'Neuen Kredit hinzufügen'}
|
||||
</button>
|
||||
|
||||
{/* Add Dialog */}
|
||||
{showAddDialog && (
|
||||
<div className="bg-white rounded-lg shadow p-6 border-2 border-blue-200">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<CreditCard className="text-blue-600" size={20} />
|
||||
Neuer Kredit
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Bezeichnung</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newKredit.name}
|
||||
onChange={(e) => setNewKredit({ ...newKredit, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="z.B. Immobilienfinanzierung"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kreditgeber</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newKredit.kreditgeber}
|
||||
onChange={(e) => setNewKredit({ ...newKredit, kreditgeber: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="z.B. Sparkasse"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Typ</label>
|
||||
<select
|
||||
value={newKredit.typ}
|
||||
onChange={(e) => setNewKredit({ ...newKredit, typ: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
>
|
||||
{KREDIT_TYPEN.map(typ => (
|
||||
<option key={typ.id} value={typ.id}>{typ.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Darlehensbetrag (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={newKredit.darlehensbetrag}
|
||||
onChange={(e) => setNewKredit({ ...newKredit, darlehensbetrag: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Monatsrate (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={newKredit.monatsrate}
|
||||
onChange={(e) => setNewKredit({ ...newKredit, monatsrate: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Zinssatz (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={newKredit.zinssatz}
|
||||
onChange={(e) => setNewKredit({ ...newKredit, zinssatz: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Aktuelle Restschuld (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={newKredit.restschuld}
|
||||
onChange={(e) => setNewKredit({ ...newKredit, restschuld: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Laufzeit bis</label>
|
||||
<input
|
||||
type="date"
|
||||
value={newKredit.laufzeitBis}
|
||||
onChange={(e) => setNewKredit({ ...newKredit, laufzeitBis: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={addKredit}
|
||||
disabled={!newKredit.name || !newKredit.monatsrate}
|
||||
className="bg-blue-600 text-white py-2 px-6 rounded-lg hover:bg-blue-700 flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<Save size={16} /> Speichern
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kredite Tabelle */}
|
||||
{kredite.length > 0 ? (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-100 border-b-2 border-gray-300">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-semibold">Name</th>
|
||||
<th className="text-left py-3 px-4 font-semibold">Kreditgeber</th>
|
||||
<th className="text-left py-3 px-4 font-semibold">Typ</th>
|
||||
<th className="text-right py-3 px-4 font-semibold">Monatsrate</th>
|
||||
<th className="text-right py-3 px-4 font-semibold">Restschuld</th>
|
||||
<th className="text-right py-3 px-4 font-semibold">Zinssatz</th>
|
||||
<th className="text-left py-3 px-4 font-semibold">Laufzeit bis</th>
|
||||
<th className="py-3 px-2 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{kredite.map(kredit => {
|
||||
const typInfo = KREDIT_TYPEN.find(t => t.id === kredit.typ) || KREDIT_TYPEN[4];
|
||||
const Icon = typInfo.icon;
|
||||
return (
|
||||
<tr key={kredit.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="py-3 px-4 font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={typInfo.color} size={18} />
|
||||
{kredit.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-gray-600">{kredit.kreditgeber}</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs ${typInfo.bg} ${typInfo.color}`}>
|
||||
<Icon size={12} />
|
||||
{typInfo.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right font-semibold">
|
||||
{formatCurrency(kredit.monatsrate)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-gray-600">
|
||||
{formatCurrency(kredit.restschuld)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-gray-600">
|
||||
{kredit.zinssatz}%
|
||||
</td>
|
||||
<td className="py-3 px-4 text-gray-600">
|
||||
{kredit.laufzeitBis ? new Date(kredit.laufzeitBis).toLocaleDateString('de-DE') : '-'}
|
||||
</td>
|
||||
<td className="py-3 px-2">
|
||||
<button
|
||||
onClick={() => deleteKredit(kredit.id)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center">
|
||||
<div className="bg-gray-100 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CreditCard className="text-gray-400" size={32} />
|
||||
</div>
|
||||
<p className="text-gray-500">Noch keine Kredite vorhanden</p>
|
||||
<p className="text-sm text-gray-400 mt-2">Füge deinen ersten Kredit hinzu!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { DollarSign, Plus, Trash2, Save, AlertCircle, X, TrendingUp, TrendingDown, Edit2, CreditCard } from 'lucide-react';
|
||||
import { kostenplanungAPI, krediteAPI } from '../api';
|
||||
|
||||
export default function PrivateKosten() {
|
||||
const [planung, setPlanung] = useState([]);
|
||||
const [kredite, setKredite] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedJahr, setSelectedJahr] = useState(2026);
|
||||
const [showAddRow, setShowAddRow] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
|
||||
const [newRow, setNewRow] = useState({
|
||||
name: '',
|
||||
betrag: '',
|
||||
istEinnahme: false
|
||||
});
|
||||
|
||||
const [editRow, setEditRow] = useState({
|
||||
name: '',
|
||||
betrag: '',
|
||||
istEinnahme: false
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [selectedJahr]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Lade private Kosten
|
||||
const data = await kostenplanungAPI.getAll({
|
||||
jahr: selectedJahr,
|
||||
typ: 'privat'
|
||||
});
|
||||
|
||||
// Lade Kredite für automatische Anzeige
|
||||
const krediteData = await krediteAPI.getAll();
|
||||
|
||||
setPlanung(data.map(p => ({
|
||||
...p,
|
||||
monatsbetrag: p.monate ? (p.monate.reduce((a, v) => a + (parseFloat(v) || 0), 0) / 12) : 0
|
||||
})));
|
||||
|
||||
// Filtere aktive Kredite (mit Restschuld > 0)
|
||||
const aktiveKredite = krediteData.filter(k => {
|
||||
const restschuld = parseFloat(k.restschuld) || parseFloat(k.ursprungsschuld) || 0;
|
||||
const rate = parseFloat(k.rate) || parseFloat(k.monatsrate) || 0;
|
||||
return restschuld > 0.01 && rate > 0;
|
||||
}).map(k => ({
|
||||
...k,
|
||||
isKredit: true,
|
||||
monatsrate: parseFloat(k.rate) || parseFloat(k.monatsrate) || 0
|
||||
}));
|
||||
|
||||
setKredite(aktiveKredite);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden: ' + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addRow = async () => {
|
||||
if (!newRow.name || !newRow.betrag) return;
|
||||
|
||||
try {
|
||||
const betrag = parseFloat(newRow.betrag) || 0;
|
||||
const data = {
|
||||
objekt: 'Privat',
|
||||
name: newRow.name,
|
||||
kategorie: 'Private Kosten',
|
||||
typ: 'privat',
|
||||
ist_einnahme: newRow.istEinnahme,
|
||||
monate: Array(12).fill(betrag),
|
||||
jahr: selectedJahr,
|
||||
unterkategorie: 'sonstiges'
|
||||
};
|
||||
|
||||
await kostenplanungAPI.create(data);
|
||||
await loadData();
|
||||
|
||||
setNewRow({ name: '', betrag: '', istEinnahme: false });
|
||||
setShowAddRow(false);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Speichern: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (p) => {
|
||||
setEditingId(p.id);
|
||||
setEditRow({
|
||||
name: p.name,
|
||||
betrag: (p.monate ? (p.monate.reduce((a, v) => a + (parseFloat(v) || 0), 0) / 12) : 0).toString(),
|
||||
istEinnahme: p.ist_einnahme
|
||||
});
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
try {
|
||||
const p = planung.find(item => item.id === editingId);
|
||||
if (!p) return;
|
||||
|
||||
const betrag = parseFloat(editRow.betrag) || 0;
|
||||
const dataToSend = {
|
||||
name: editRow.name,
|
||||
ist_einnahme: editRow.istEinnahme,
|
||||
monate: Array(12).fill(betrag),
|
||||
objekt: p.objekt || 'Privat',
|
||||
kategorie: p.kategorie || 'Private Kosten',
|
||||
typ: p.typ || 'privat',
|
||||
unterkategorie: p.unterkategorie || 'sonstiges',
|
||||
jahr: p.jahr || selectedJahr
|
||||
};
|
||||
|
||||
console.log('Sending UPDATE:', editingId, dataToSend);
|
||||
const result = await kostenplanungAPI.update(editingId, dataToSend);
|
||||
console.log('UPDATE result:', result);
|
||||
|
||||
// OPTIMISTISCH: Lokal aktualisieren falls Backend ist_einnahme nicht speichert
|
||||
setPlanung(planung.map(p =>
|
||||
p.id === editingId
|
||||
? { ...p, name: editRow.name, ist_einnahme: editRow.istEinnahme, monate: Array(12).fill(betrag) }
|
||||
: p
|
||||
));
|
||||
|
||||
setEditingId(null);
|
||||
} catch (err) {
|
||||
console.error('UPDATE error:', err);
|
||||
setError('Fehler beim Aktualisieren: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRow = async (id) => {
|
||||
if (confirm('Position wirklich löschen?')) {
|
||||
try {
|
||||
await kostenplanungAPI.delete(id);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Löschen: ' + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Berechnungen - mit Krediten!
|
||||
const kreditSumme = kredite.reduce((sum, k) => sum + k.monatsrate, 0);
|
||||
|
||||
const einnahmen = planung.filter(p => p.ist_einnahme).reduce((sum, p) => {
|
||||
const monatlich = p.monate ? (p.monate.reduce((a, v) => a + (parseFloat(v) || 0), 0) / 12) : 0;
|
||||
return sum + monatlich;
|
||||
}, 0);
|
||||
|
||||
// Ausgaben = Manuelle Einträge + Kredite
|
||||
const manuelleAusgaben = planung.filter(p => !p.ist_einnahme).reduce((sum, p) => {
|
||||
const monatlich = p.monate ? (p.monate.reduce((a, v) => a + (parseFloat(v) || 0), 0) / 12) : 0;
|
||||
return sum + monatlich;
|
||||
}, 0);
|
||||
|
||||
const ausgaben = manuelleAusgaben + kreditSumme;
|
||||
|
||||
const bilanz = einnahmen - ausgaben;
|
||||
const jahresBilanz = bilanz * 12;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Lade private Kosten...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-purple-600 to-indigo-700 rounded-lg p-6 text-white">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<DollarSign size={28} />
|
||||
Private Kosten
|
||||
</h2>
|
||||
<p className="text-purple-200 mt-1">Monatliche Übersicht Einnahmen & Ausgaben</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={selectedJahr}
|
||||
onChange={(e) => setSelectedJahr(parseInt(e.target.value))}
|
||||
className="bg-white/10 border border-white/20 rounded px-3 py-2 text-white"
|
||||
>
|
||||
<option value={2025} className="text-gray-900">2025</option>
|
||||
<option value={2026} className="text-gray-900">2026</option>
|
||||
<option value={2027} className="text-gray-900">2027</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Übersicht */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-lg shadow p-4 border-l-4 border-emerald-500">
|
||||
<p className="text-sm text-gray-600">Einnahmen/Monat</p>
|
||||
<p className="text-2xl font-bold text-emerald-600">
|
||||
{einnahmen.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4 border-l-4 border-red-500">
|
||||
<p className="text-sm text-gray-600">Ausgaben/Monat</p>
|
||||
<p className="text-2xl font-bold text-red-600">
|
||||
{ausgaben.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</p>
|
||||
{kreditSumme > 0 && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
(inkl. {kreditSumme.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })} Kredite)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4 border-l-4 border-purple-500">
|
||||
<p className="text-sm text-gray-600">Monatsbilanz</p>
|
||||
<p className={`text-2xl font-bold ${bilanz >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
|
||||
{bilanz.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4 border-l-4 border-blue-500">
|
||||
<p className="text-sm text-gray-600">Jahresbilanz</p>
|
||||
<p className={`text-2xl font-bold ${jahresBilanz >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
|
||||
{jahresBilanz.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-2 text-red-700">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-auto"><X size={16} /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Button */}
|
||||
<button
|
||||
onClick={() => setShowAddRow(!showAddRow)}
|
||||
className="w-full bg-purple-600 text-white py-3 rounded-lg hover:bg-purple-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus size={20} />
|
||||
{showAddRow ? 'Abbrechen' : 'Neue Position hinzufügen'}
|
||||
</button>
|
||||
|
||||
{/* Add Form */}
|
||||
{showAddRow && (
|
||||
<div className="bg-white rounded-lg shadow p-6 border-2 border-purple-200">
|
||||
<h3 className="text-lg font-semibold mb-4">Neue Position</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Bezeichnung</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRow.name}
|
||||
onChange={(e) => setNewRow({ ...newRow, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="z.B. Miete, Gehalt, Versicherung"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Betrag/Monat (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={newRow.betrag}
|
||||
onChange={(e) => setNewRow({ ...newRow, betrag: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Typ</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setNewRow({ ...newRow, istEinnahme: false })}
|
||||
className={`flex-1 py-2 px-3 rounded flex items-center justify-center gap-1 ${!newRow.istEinnahme ? 'bg-red-100 text-red-700 border-2 border-red-300' : 'bg-gray-100 text-gray-600'}`}
|
||||
>
|
||||
<TrendingDown size={16} /> Ausgabe
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setNewRow({ ...newRow, istEinnahme: true })}
|
||||
className={`flex-1 py-2 px-3 rounded flex items-center justify-center gap-1 ${newRow.istEinnahme ? 'bg-emerald-100 text-emerald-700 border-2 border-emerald-300' : 'bg-gray-100 text-gray-600'}`}
|
||||
>
|
||||
<TrendingUp size={16} /> Einnahme
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={addRow}
|
||||
className="mt-4 bg-purple-600 text-white py-2 px-4 rounded hover:bg-purple-700 flex items-center gap-2"
|
||||
>
|
||||
<Save size={16} /> Speichern
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-100 border-b-2 border-gray-300">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-semibold">Bezeichnung</th>
|
||||
<th className="text-right py-3 px-4 font-semibold">Betrag/Monat</th>
|
||||
<th className="text-center py-3 px-4 font-semibold">Typ</th>
|
||||
<th className="text-right py-3 px-4 font-semibold">Jahresbetrag</th>
|
||||
<th className="py-3 px-4 w-20"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{planung.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-8 text-center text-gray-500">
|
||||
Keine Einträge vorhanden. Klicke auf "Neue Position hinzufügen".
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{planung.map(p => {
|
||||
const monatlich = p.monate ? (p.monate.reduce((a, v) => a + (parseFloat(v) || 0), 0) / 12) : 0;
|
||||
const jahrlich = monatlich * 12;
|
||||
|
||||
if (editingId === p.id) {
|
||||
return (
|
||||
<tr key={p.id} className="border-b border-gray-100 bg-blue-50">
|
||||
<td className="py-2 px-4">
|
||||
<input
|
||||
type="text"
|
||||
value={editRow.name}
|
||||
onChange={(e) => setEditRow({ ...editRow, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-2 py-1"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-4">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={editRow.betrag}
|
||||
onChange={(e) => setEditRow({ ...editRow, betrag: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-2 py-1 text-right"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-4 text-center">
|
||||
<div className="flex justify-center gap-1">
|
||||
<button
|
||||
onClick={() => setEditRow({ ...editRow, istEinnahme: false })}
|
||||
className={`px-2 py-1 rounded text-xs ${!editRow.istEinnahme ? 'bg-red-100 text-red-700' : 'bg-gray-100'}`}
|
||||
>
|
||||
Ausgabe
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditRow({ ...editRow, istEinnahme: true })}
|
||||
className={`px-2 py-1 rounded text-xs ${editRow.istEinnahme ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100'}`}
|
||||
>
|
||||
Einnahme
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 px-4 text-right font-medium">
|
||||
{(parseFloat(editRow.betrag || 0) * 12).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</td>
|
||||
<td className="py-2 px-4">
|
||||
<div className="flex gap-1">
|
||||
<button onClick={saveEdit} className="text-emerald-600 hover:text-emerald-800" title="Speichern">
|
||||
<Save size={16} />
|
||||
</button>
|
||||
<button onClick={() => setEditingId(null)} className="text-gray-400 hover:text-gray-600" title="Abbrechen">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={p.id} className={`border-b border-gray-100 hover:bg-gray-50 ${p.ist_einnahme ? 'bg-emerald-50/30' : ''}`}>
|
||||
<td className="py-3 px-4 font-medium">{p.name}</td>
|
||||
<td className={`py-3 px-4 text-right font-semibold ${p.ist_einnahme ? 'text-emerald-600' : 'text-red-600'}`}>
|
||||
{p.ist_einnahme ? '+' : '-'}{monatlich.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs ${p.ist_einnahme ? 'bg-emerald-100 text-emerald-700' : 'bg-red-100 text-red-700'}`}>
|
||||
{p.ist_einnahme ? <TrendingUp size={12} /> : <TrendingDown size={12} />}
|
||||
{p.ist_einnahme ? 'Einnahme' : 'Ausgabe'}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`py-3 px-4 text-right ${p.ist_einnahme ? 'text-emerald-600' : 'text-red-600'}`}>
|
||||
{p.ist_einnahme ? '+' : '-'}{jahrlich.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => startEdit(p)} className="text-blue-400 hover:text-blue-600" title="Bearbeiten">
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button onClick={() => deleteRow(p.id)} className="text-red-400 hover:text-red-600" title="Löschen">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Automatisch geladene Kredite */}
|
||||
{kredite.length > 0 && (
|
||||
<>
|
||||
<tr className="bg-gray-100 border-t-2 border-gray-300">
|
||||
<td colSpan={5} className="py-2 px-4 font-bold text-gray-700 flex items-center gap-2">
|
||||
<CreditCard size={16} className="text-blue-600" />
|
||||
Kredite (automatisch)
|
||||
</td>
|
||||
</tr>
|
||||
{kredite.map(k => (
|
||||
<tr key={`kredit-${k.id}`} className="border-b border-gray-100 bg-blue-50/50">
|
||||
<td className="py-3 px-4 font-medium flex items-center gap-2">
|
||||
{k.name || k.zweck || 'Kredit'}
|
||||
<span className="text-xs text-gray-500">(Rest: {(parseFloat(k.restschuld) || parseFloat(k.ursprungsschuld) || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })})</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right font-semibold text-red-600">
|
||||
-{k.monatsrate.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs bg-orange-100 text-orange-700">
|
||||
<CreditCard size={12} />
|
||||
Kredit
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-red-600">
|
||||
-{(k.monatsrate * 12).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-xs text-gray-400">Auto</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Clock, Plus, Trash2, Edit2, Save, X, AlertCircle, Package } from 'lucide-react';
|
||||
import { stundenAPI } from '../api';
|
||||
|
||||
export default function Stunden() {
|
||||
const [stunden, setStunden] = useState([]);
|
||||
const [pauschalen, setPauschalen] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [showPauschalForm, setShowPauschalForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [isPauschal, setIsPauschal] = useState(false);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
datum: new Date().toISOString().split('T')[0],
|
||||
kunde: '',
|
||||
beschreibung: '',
|
||||
stunden: '',
|
||||
stundensatz: 80,
|
||||
status: 'offen',
|
||||
ist_pauschal: false
|
||||
});
|
||||
|
||||
const [pauschalForm, setPauschalForm] = useState({
|
||||
datum: new Date().toISOString().split('T')[0],
|
||||
kunde: '',
|
||||
beschreibung: '',
|
||||
pauschal_betrag: '',
|
||||
status: 'offen'
|
||||
});
|
||||
|
||||
// Lade Stunden beim Start
|
||||
useEffect(() => {
|
||||
loadStunden();
|
||||
}, []);
|
||||
|
||||
const loadStunden = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await stundenAPI.getAll();
|
||||
// Transformiere Daten für das Frontend-Format
|
||||
const transformedData = data.map(s => ({
|
||||
id: s.id,
|
||||
datum: s.datum,
|
||||
kunde: s.kunde,
|
||||
beschreibung: s.beschreibung,
|
||||
stunden: parseFloat(s.stunden) || 0,
|
||||
stundensatz: parseFloat(s.stundensatz) || 0,
|
||||
betrag: parseFloat(s.betrag) || 0,
|
||||
status: s.status,
|
||||
ist_pauschal: s.ist_pauschal || false
|
||||
}));
|
||||
|
||||
// Trenne Pauschalen und normale Stunden
|
||||
const pauschalEntries = transformedData.filter(s => s.ist_pauschal);
|
||||
const normalEntries = transformedData.filter(s => !s.ist_pauschal);
|
||||
|
||||
setStunden(normalEntries);
|
||||
setPauschalen(pauschalEntries);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden der Stunden: ' + err.message);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addStunden = async () => {
|
||||
if (!form.kunde) return;
|
||||
|
||||
try {
|
||||
let data;
|
||||
|
||||
if (isPauschal) {
|
||||
// Pauschalpreis
|
||||
if (!form.pauschal_betrag) return;
|
||||
data = {
|
||||
datum: form.datum,
|
||||
kunde: form.kunde,
|
||||
beschreibung: form.beschreibung,
|
||||
stunden: 0,
|
||||
stundensatz: 0,
|
||||
betrag: parseFloat(form.pauschal_betrag),
|
||||
ist_pauschal: true
|
||||
};
|
||||
} else {
|
||||
// Normale Stunden
|
||||
if (!form.stunden) return;
|
||||
data = {
|
||||
datum: form.datum,
|
||||
kunde: form.kunde,
|
||||
beschreibung: form.beschreibung,
|
||||
stunden: parseFloat(form.stunden),
|
||||
stundensatz: parseFloat(form.stundensatz),
|
||||
ist_pauschal: false
|
||||
};
|
||||
}
|
||||
|
||||
await stundenAPI.create(data);
|
||||
await loadStunden();
|
||||
|
||||
setForm({
|
||||
datum: new Date().toISOString().split('T')[0],
|
||||
kunde: '',
|
||||
beschreibung: '',
|
||||
stunden: '',
|
||||
stundensatz: 80,
|
||||
pauschal_betrag: '',
|
||||
status: 'offen',
|
||||
ist_pauschal: false
|
||||
});
|
||||
setIsPauschal(false);
|
||||
setShowForm(false);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Speichern: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const updateStunden = async (id, updates) => {
|
||||
try {
|
||||
const stunde = stunden.find(s => s.id === id);
|
||||
const data = {
|
||||
datum: updates.datum || stunde.datum,
|
||||
kunde: updates.kunde || stunde.kunde,
|
||||
beschreibung: updates.beschreibung !== undefined ? updates.beschreibung : stunde.beschreibung,
|
||||
stunden: updates.stunden !== undefined ? parseFloat(updates.stunden) : stunde.stunden,
|
||||
stundensatz: updates.stundensatz !== undefined ? parseFloat(updates.stundensatz) : stunde.stundensatz,
|
||||
status: updates.status || stunde.status
|
||||
};
|
||||
|
||||
await stundenAPI.update(id, data);
|
||||
await loadStunden();
|
||||
setEditingId(null);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Aktualisieren: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteStunden = async (id) => {
|
||||
if (confirm('Eintrag wirklich löschen?')) {
|
||||
try {
|
||||
await stundenAPI.delete(id);
|
||||
await loadStunden();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Löschen: ' + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const offeneStunden = stunden.filter(s => s.status === 'offen');
|
||||
const abgerechneteStunden = stunden.filter(s => s.status === 'abgerechnet');
|
||||
const offenePauschalen = pauschalen.filter(p => p.status === 'offen');
|
||||
const abgerechnetePauschalen = pauschalen.filter(p => p.status === 'abgerechnet');
|
||||
|
||||
const totalOffenStunden = offeneStunden.reduce((sum, s) => sum + s.betrag, 0);
|
||||
const totalAbgerechnetStunden = abgerechneteStunden.reduce((sum, s) => sum + s.betrag, 0);
|
||||
const totalOffenPauschal = offenePauschalen.reduce((sum, p) => sum + p.betrag, 0);
|
||||
const totalAbgerechnetPauschal = abgerechnetePauschalen.reduce((sum, p) => sum + p.betrag, 0);
|
||||
|
||||
const totalOffen = totalOffenStunden + totalOffenPauschal;
|
||||
const totalAbgerechnet = totalAbgerechnetStunden + totalAbgerechnetPauschal;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Lade Stunden...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-2 text-red-700">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-auto">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Übersicht */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
||||
<p className="text-sm text-orange-700">Offene Stunden</p>
|
||||
<p className="text-2xl font-bold text-orange-800">{totalOffenStunden.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</p>
|
||||
<p className="text-sm text-orange-600">{offeneStunden.length} Einträge</p>
|
||||
</div>
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<p className="text-sm text-green-700">Abgerechnete Stunden</p>
|
||||
<p className="text-2xl font-bold text-green-800">{totalAbgerechnetStunden.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</p>
|
||||
<p className="text-sm text-green-600">{abgerechneteStunden.length} Einträge</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<p className="text-sm text-purple-700">Offene Pauschalen</p>
|
||||
<p className="text-2xl font-bold text-purple-800">{totalOffenPauschal.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</p>
|
||||
<p className="text-sm text-purple-600">{offenePauschalen.length} Einträge</p>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-700">Abgerechnete Pauschalen</p>
|
||||
<p className="text-2xl font-bold text-blue-800">{totalAbgerechnetPauschal.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</p>
|
||||
<p className="text-sm text-blue-600">{abgerechnetePauschalen.length} Einträge</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Neuer Eintrag */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => { setShowForm(!showForm); setIsPauschal(false); }}
|
||||
className="flex-1 bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Clock size={20} />
|
||||
{showForm && !isPauschal ? 'Abbrechen' : 'Stunden erfassen'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowForm(!showForm); setIsPauschal(true); }}
|
||||
className="flex-1 bg-purple-600 text-white py-3 rounded-lg hover:bg-purple-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Package size={20} />
|
||||
{showForm && isPauschal ? 'Abbrechen' : 'Pauschalpreis erfassen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
{/* Toggle: Stunden vs Pauschal */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
onClick={() => setIsPauschal(false)}
|
||||
className={`flex-1 py-2 px-4 rounded-lg flex items-center justify-center gap-2 transition-colors ${
|
||||
!isPauschal
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Clock size={18} />
|
||||
Stundensatz
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsPauschal(true)}
|
||||
className={`flex-1 py-2 px-4 rounded-lg flex items-center justify-center gap-2 transition-colors ${
|
||||
isPauschal
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Package size={18} />
|
||||
Pauschalpreis
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Datum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.datum}
|
||||
onChange={(e) => setForm({ ...form, datum: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className={isPauschal ? 'md:col-span-2' : ''}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kunde</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.kunde}
|
||||
onChange={(e) => setForm({ ...form, kunde: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="z.B. Muster GmbH"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isPauschal && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Stunden</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={form.stunden}
|
||||
onChange={(e) => setForm({ ...form, stunden: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="2.5"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.beschreibung}
|
||||
onChange={(e) => setForm({ ...form, beschreibung: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="Was wurde gemacht?"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Stundensatz (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.stundensatz}
|
||||
onChange={(e) => setForm({ ...form, stundensatz: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isPauschal && (
|
||||
<>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.beschreibung}
|
||||
onChange={(e) => setForm({ ...form, beschreibung: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="Projektbezeichnung / Leistung"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Pauschalpreis (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.pauschal_betrag}
|
||||
onChange={(e) => setForm({ ...form, pauschal_betrag: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="2500.00"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={addStunden}
|
||||
disabled={!form.kunde || (isPauschal ? !form.pauschal_betrag : !form.stunden)}
|
||||
className="mt-4 w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
💾 Speichern
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste */}
|
||||
{stunden.length > 0 ? (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4">Datum</th>
|
||||
<th className="text-left py-3 px-4">Kunde</th>
|
||||
<th className="text-left py-3 px-4">Beschreibung</th>
|
||||
<th className="text-right py-3 px-4">Stunden</th>
|
||||
<th className="text-right py-3 px-4">Betrag</th>
|
||||
<th className="text-center py-3 px-4">Status</th>
|
||||
<th className="py-3 px-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stunden.sort((a, b) => new Date(b.datum) - new Date(a.datum)).map(s => (
|
||||
<tr key={s.id} className="border-b border-gray-100">
|
||||
<td className="py-3 px-4">{s.datum}</td>
|
||||
<td className="py-3 px-4">{s.kunde}</td>
|
||||
<td className="py-3 px-4">{s.beschreibung}</td>
|
||||
<td className="text-right py-3 px-4">{s.stunden}h</td>
|
||||
<td className="text-right py-3 px-4 font-semibold">{s.betrag.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
<td className="text-center py-3 px-4">
|
||||
<select
|
||||
value={s.status}
|
||||
onChange={(e) => updateStunden(s.id, { status: e.target.value })}
|
||||
className={`text-sm border rounded px-2 py-1 ${s.status === 'abgerechnet' ? 'bg-green-100 text-green-800 border-green-300' : 'bg-orange-100 text-orange-800 border-orange-300'}`}
|
||||
>
|
||||
<option value="offen">🟠 Offen</option>
|
||||
<option value="abgerechnet">🟢 Abgerechnet</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="py-3 px-2">
|
||||
<button onClick={() => deleteStunden(s.id)} className="text-red-400 hover:text-red-600">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center text-gray-500">Noch keine Stunden erfasst.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { LogIn, AlertCircle } from 'lucide-react';
|
||||
|
||||
export default function Login({ onSwitchToSetup }) {
|
||||
const { login } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-500 to-blue-700 p-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md p-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-blue-100 rounded-full mb-4">
|
||||
<LogIn className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Täger Buchhaltung</h1>
|
||||
<p className="text-gray-600 mt-2">Bitte einloggen</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition"
|
||||
placeholder="Dein Username"
|
||||
required
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition"
|
||||
placeholder="Dein Passwort"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-semibold py-3 px-4 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<span>Lade...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="w-5 h-5" />
|
||||
<span>Login</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{onSwitchToSetup && (
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
onClick={onSwitchToSetup}
|
||||
className="text-sm text-blue-600 hover:text-blue-700 underline"
|
||||
>
|
||||
Erste Anmeldung? Hier Account erstellen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
export default function ProtectedRoute({ children, adminOnly = false }) {
|
||||
const { isAuthenticated, isAdmin, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-xl">⏳ Lade...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null; // Wird vom Parent-Component gehandhabt (Login anzeigen)
|
||||
}
|
||||
|
||||
if (adminOnly && !isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="bg-red-100 text-red-700 p-6 rounded-lg max-w-md">
|
||||
<h2 className="font-bold text-lg mb-2">❌ Zugriff verweigert</h2>
|
||||
<p>Diese Seite ist nur für Administratoren zugänglich.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { UserPlus, Shield, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
|
||||
export default function SetupWizard() {
|
||||
const { setup } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwörter stimmen nicht überein');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('Passwort muss mindestens 6 Zeichen lang sein');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await setup(username, password);
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-500 to-green-700 p-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md p-8 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4">
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Willkommen bei SteuerFlow!</h1>
|
||||
<p className="text-gray-600">
|
||||
Admin-Account erfolgreich erstellt. Du wirst automatisch weitergeleitet...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-500 to-indigo-700 p-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md p-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-indigo-100 rounded-full mb-4">
|
||||
<UserPlus className="w-8 h-8 text-indigo-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Erste Anmeldung</h1>
|
||||
<p className="text-gray-600 mt-2">Admin-Account erstellen</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg flex items-start gap-3">
|
||||
<Shield className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-blue-700">
|
||||
Dieser Account hat Administrator-Rechte und kann weitere Benutzer verwalten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition"
|
||||
placeholder="Dein Username"
|
||||
required
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition"
|
||||
placeholder="Mindestens 6 Zeichen"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Passwort bestätigen
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition"
|
||||
placeholder="Passwort wiederholen"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white font-semibold py-3 px-4 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<span>Erstelle Account...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="w-5 h-5" />
|
||||
<span>Account erstellen</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { UserPlus, X } from 'lucide-react';
|
||||
|
||||
export default function AddUserForm({ newUser, setNewUser, onSubmit, onCancel }) {
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSubmit(newUser);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Neuen User anlegen</h3>
|
||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newUser.username}
|
||||
onChange={(e) => setNewUser({ ...newUser, username: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="z.B. max.mustermann"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newUser.password}
|
||||
onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="Mindestens 6 Zeichen"
|
||||
minLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Rolle</label>
|
||||
<select
|
||||
value={newUser.role}
|
||||
onChange={(e) => setNewUser({ ...newUser, role: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="md:col-span-3 flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
Erstellen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="bg-gray-300 hover:bg-gray-400 text-gray-700 px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { AlertCircle, CheckCircle } from 'lucide-react';
|
||||
|
||||
export default function AlertMessage({ error, success }) {
|
||||
if (!error && !success) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-lg flex items-start gap-3">
|
||||
<CheckCircle className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-green-700">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Key } from 'lucide-react';
|
||||
|
||||
export default function ResetPasswordForm({ user, newPassword, setNewPassword, onSubmit, onCancel }) {
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSubmit(user.id, newPassword);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
Passwort zuruecksetzen fuer: {user.username}
|
||||
</h3>
|
||||
<form onSubmit={handleSubmit} className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="Neues Passwort"
|
||||
minLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-yellow-600 hover:bg-yellow-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Key className="w-4 h-4" />
|
||||
Zuruecksetzen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="bg-gray-300 hover:bg-gray-400 text-gray-700 px-4 py-2 rounded-lg"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Key, Trash2 } from 'lucide-react';
|
||||
|
||||
export default function UserList({ users, currentUser, onResetPassword, onDeleteUser }) {
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">Username</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">Rolle</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">Status</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">Erstellt</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">Letzter Login</th>
|
||||
<th className="text-center py-3 px-4 font-medium text-gray-700">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className={user.id === currentUser?.id ? 'bg-blue-50' : ''}>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{user.username}</span>
|
||||
{user.id === currentUser?.id && (
|
||||
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
|
||||
Du
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{user.role === 'admin' ? 'Admin' : 'User'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
user.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{user.is_active ? 'Aktiv' : 'Inaktiv'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600">
|
||||
{formatDate(user.created_at)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600">
|
||||
{formatDate(user.last_login)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex justify-center gap-2">
|
||||
<button
|
||||
onClick={() => onResetPassword(user)}
|
||||
className="p-2 text-yellow-600 hover:bg-yellow-50 rounded-lg"
|
||||
title="Passwort zuruecksetzen"
|
||||
>
|
||||
<Key className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteUser(user.id, user.username)}
|
||||
disabled={user.id === currentUser?.id}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={user.id === currentUser?.id ? 'Eigener Account' : 'Loeschen'}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../../../contexts/AuthContext';
|
||||
import { Users, UserPlus, Key, Trash2, X, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import useUserManagement from './useUserManagement';
|
||||
import AlertMessage from './AlertMessage';
|
||||
import AddUserForm from './AddUserForm';
|
||||
import ResetPasswordForm from './ResetPasswordForm';
|
||||
import UserList from './UserList';
|
||||
|
||||
export default function UserManagement() {
|
||||
const { user: currentUser, getToken } = useAuth();
|
||||
const {
|
||||
users,
|
||||
loading,
|
||||
error,
|
||||
success,
|
||||
createUser,
|
||||
deleteUser,
|
||||
resetPassword,
|
||||
clearMessages
|
||||
} = useUserManagement(getToken);
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newUser, setNewUser] = useState({ username: '', password: '', role: 'user' });
|
||||
const [resetPasswordUser, setResetPasswordUser] = useState(null);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
|
||||
const handleCreateUser = async (userData) => {
|
||||
const success = await createUser(userData);
|
||||
if (success) {
|
||||
setNewUser({ username: '', password: '', role: 'user' });
|
||||
setShowAddForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId, username) => {
|
||||
await deleteUser(userId, username);
|
||||
};
|
||||
|
||||
const handleResetPassword = async (userId, password) => {
|
||||
const success = await resetPassword(userId, password);
|
||||
if (success) {
|
||||
setResetPasswordUser(null);
|
||||
setNewPassword('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelReset = () => {
|
||||
setResetPasswordUser(null);
|
||||
setNewPassword('');
|
||||
clearMessages();
|
||||
};
|
||||
|
||||
const handleCancelAdd = () => {
|
||||
setShowAddForm(false);
|
||||
setNewUser({ username: '', password: '', role: 'user' });
|
||||
clearMessages();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Users className="w-6 h-6" />
|
||||
Benutzerverwaltung
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
User hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AlertMessage error={error} success={success} />
|
||||
|
||||
{showAddForm && (
|
||||
<AddUserForm
|
||||
newUser={newUser}
|
||||
setNewUser={setNewUser}
|
||||
onSubmit={handleCreateUser}
|
||||
onCancel={handleCancelAdd}
|
||||
/>
|
||||
)}
|
||||
|
||||
{resetPasswordUser && (
|
||||
<ResetPasswordForm
|
||||
user={resetPasswordUser}
|
||||
newPassword={newPassword}
|
||||
setNewPassword={setNewPassword}
|
||||
onSubmit={handleResetPassword}
|
||||
onCancel={handleCancelReset}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UserList
|
||||
users={users}
|
||||
currentUser={currentUser}
|
||||
onResetPassword={(user) => {
|
||||
setResetPasswordUser(user);
|
||||
setNewPassword('');
|
||||
clearMessages();
|
||||
}}
|
||||
onDeleteUser={handleDeleteUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Keine Barrel-Exporte mehr - direkte Imports verwenden
|
||||
export { default as UserManagement } from './UserManagement';
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
export default function useUserManagement(getToken) {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setError('');
|
||||
setSuccess('');
|
||||
}, []);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch(API_URL + '/auth/users', {
|
||||
headers: { Authorization: 'Bearer ' + getToken() }
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'Fehler beim Laden');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setUsers(data.users);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [getToken]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
const createUser = async (userData) => {
|
||||
clearMessages();
|
||||
|
||||
try {
|
||||
const res = await fetch(API_URL + '/auth/users', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer ' + getToken()
|
||||
},
|
||||
body: JSON.stringify(userData)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Fehler');
|
||||
}
|
||||
|
||||
setSuccess('User erstellt: ' + data.user.username);
|
||||
await fetchUsers();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUser = async (userId, username) => {
|
||||
if (!confirm('User loeschen: ' + username + '?')) return false;
|
||||
|
||||
clearMessages();
|
||||
|
||||
try {
|
||||
const res = await fetch(API_URL + '/auth/users/' + userId, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: 'Bearer ' + getToken() }
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Fehler');
|
||||
}
|
||||
|
||||
setSuccess('User geloescht: ' + username);
|
||||
await fetchUsers();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetPassword = async (userId, newPassword) => {
|
||||
clearMessages();
|
||||
|
||||
try {
|
||||
const res = await fetch(API_URL + '/auth/users/' + userId + '/password', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer ' + getToken()
|
||||
},
|
||||
body: JSON.stringify({ newPassword })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Fehler');
|
||||
}
|
||||
|
||||
setSuccess('Passwort zurueckgesetzt');
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
users,
|
||||
loading,
|
||||
error,
|
||||
success,
|
||||
fetchUsers,
|
||||
createUser,
|
||||
deleteUser,
|
||||
resetPassword,
|
||||
clearMessages
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,711 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Users, Plus, Trash2, Edit2, Save, X, UserPlus, Building2, Calendar } from 'lucide-react';
|
||||
import { mieterAPI, objekteAPI } from '../../api-nebenkosten';
|
||||
|
||||
export default function Mieter() {
|
||||
const [mieter, setMieter] = useState([]);
|
||||
const [objekte, setObjekte] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [editingField, setEditingField] = useState(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
|
||||
// Modal State für komplexes Editing
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editingMieter, setEditingMieter] = useState(null);
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
telefon: '',
|
||||
adresse: '',
|
||||
objekt_id: '',
|
||||
kaltmiete: '',
|
||||
nebenkosten_vorauszahlung: '',
|
||||
vertragsbeginn: '',
|
||||
vertragsende: ''
|
||||
});
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
telefon: '',
|
||||
adresse: '',
|
||||
objekt_id: '',
|
||||
wohnflaeche_qm: '',
|
||||
nebenkosten_vorauszahlung: '',
|
||||
kaltmiete: '',
|
||||
vertragsbeginn: new Date().toISOString().split('T')[0],
|
||||
vertragsende: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadMieter();
|
||||
loadObjekte();
|
||||
}, []);
|
||||
|
||||
const loadMieter = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Mieter mit Mietvertrags-Info laden
|
||||
const data = await mieterAPI.getAll();
|
||||
// Mieter-Objekte zuordnen (wir holen zusätzlich die Mietverträge)
|
||||
const mieterMitVertraegen = await Promise.all(
|
||||
data.map(async (m) => {
|
||||
try {
|
||||
const vertraege = await mieterAPI.getMietvertraege(m.id);
|
||||
const aktiverVertrag = vertraege.find(v => v.ist_aktuell) || vertraege[0];
|
||||
return { ...m, mietvertrag: aktiverVertrag };
|
||||
} catch {
|
||||
return { ...m, mietvertrag: null };
|
||||
}
|
||||
})
|
||||
);
|
||||
setMieter(mieterMitVertraegen);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden: ' + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadObjekte = async () => {
|
||||
try {
|
||||
const data = await objekteAPI.getAll();
|
||||
setObjekte(data);
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden der Objekte:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const addMieter = async () => {
|
||||
if (!form.name || !form.objekt_id) return;
|
||||
|
||||
try {
|
||||
// Neu: Mieter mit Mietvertrag erstellen
|
||||
await mieterAPI.createWithContract({
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
telefon: form.telefon,
|
||||
adresse: form.adresse,
|
||||
objekt_id: form.objekt_id, // UUID als String, nicht parseInt!
|
||||
wohnflaeche_qm: parseFloat(form.wohnflaeche_qm) || 0,
|
||||
kaltmiete: parseFloat(form.kaltmiete) || 0,
|
||||
nebenkosten_vorauszahlung: parseFloat(form.nebenkosten_vorauszahlung) || 0,
|
||||
vertragsbeginn: form.vertragsbeginn
|
||||
});
|
||||
|
||||
await loadMieter();
|
||||
|
||||
setForm({
|
||||
name: '',
|
||||
email: '',
|
||||
telefon: '',
|
||||
adresse: '',
|
||||
objekt_id: '',
|
||||
wohnflaeche_qm: '',
|
||||
nebenkosten_vorauszahlung: '',
|
||||
kaltmiete: '',
|
||||
vertragsbeginn: new Date().toISOString().split('T')[0],
|
||||
vertragsende: ''
|
||||
});
|
||||
setShowAddForm(false);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Speichern: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const startEditing = (m, field, value) => {
|
||||
setEditingId(m.id);
|
||||
setEditingField(field);
|
||||
setEditValue(value || '');
|
||||
};
|
||||
|
||||
const saveField = async (id, field) => {
|
||||
const updates = { [field]: editValue };
|
||||
await updateMieter(id, updates);
|
||||
setEditingField(null);
|
||||
setEditingId(null);
|
||||
setEditValue('');
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditingField(null);
|
||||
setEditingId(null);
|
||||
setEditValue('');
|
||||
};
|
||||
|
||||
const updateMieter = async (id, updates) => {
|
||||
try {
|
||||
const m = mieter.find(m => m.id === id);
|
||||
const data = {
|
||||
name: updates.name !== undefined ? updates.name : m.name,
|
||||
email: updates.email !== undefined ? updates.email : m.email,
|
||||
telefon: updates.telefon !== undefined ? updates.telefon : m.telefon,
|
||||
adresse: updates.adresse !== undefined ? updates.adresse : m.adresse
|
||||
};
|
||||
|
||||
await mieterAPI.update(id, data);
|
||||
await loadMieter();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Aktualisieren: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Modal-Funktionen für komplexes Editing
|
||||
const openEditModal = (m) => {
|
||||
setEditingMieter(m);
|
||||
setEditForm({
|
||||
name: m.name || '',
|
||||
email: m.email || '',
|
||||
telefon: m.telefon || '',
|
||||
adresse: m.adresse || '',
|
||||
objekt_id: m.mietvertrag?.objekt_id?.toString() || '',
|
||||
kaltmiete: m.mietvertrag?.kaltmiete?.toString() || '',
|
||||
nebenkosten_vorauszahlung: m.mietvertrag?.nebenkosten_vorauszahlung?.toString() || '',
|
||||
vertragsbeginn: m.mietvertrag?.vertragsbeginn
|
||||
? new Date(m.mietvertrag.vertragsbeginn).toISOString().split('T')[0]
|
||||
: new Date().toISOString().split('T')[0],
|
||||
vertragsende: m.mietvertrag?.vertragsende
|
||||
? new Date(m.mietvertrag.vertragsende).toISOString().split('T')[0]
|
||||
: ''
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const closeEditModal = () => {
|
||||
setShowEditModal(false);
|
||||
setEditingMieter(null);
|
||||
setEditForm({
|
||||
name: '',
|
||||
email: '',
|
||||
telefon: '',
|
||||
adresse: '',
|
||||
objekt_id: '',
|
||||
kaltmiete: '',
|
||||
nebenkosten_vorauszahlung: '',
|
||||
vertragsbeginn: '',
|
||||
vertragsende: ''
|
||||
});
|
||||
};
|
||||
|
||||
const saveEditModal = async () => {
|
||||
if (!editingMieter) return;
|
||||
|
||||
try {
|
||||
await mieterAPI.updateWithContract(editingMieter.id, {
|
||||
name: editForm.name,
|
||||
email: editForm.email,
|
||||
telefon: editForm.telefon,
|
||||
adresse: editForm.adresse,
|
||||
objekt_id: editForm.objekt_id,
|
||||
kaltmiete: parseFloat(editForm.kaltmiete) || 0,
|
||||
vorauszahlung: parseFloat(editForm.nebenkosten_vorauszahlung) || 0,
|
||||
vertragsbeginn: editForm.vertragsbeginn,
|
||||
vertragsende: editForm.vertragsende || null,
|
||||
ist_aktuell: !editForm.vertragsende || new Date(editForm.vertragsende) > new Date()
|
||||
});
|
||||
|
||||
await loadMieter();
|
||||
closeEditModal();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Aktualisieren: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMieter = async (id) => {
|
||||
if (confirm('Mieter wirklich löschen?')) {
|
||||
try {
|
||||
await mieterAPI.delete(id);
|
||||
await loadMieter();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Löschen: ' + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Lade Mieter...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-2">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Users size={24} />
|
||||
Mieter
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-2"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Neuer Mieter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h3 className="font-semibold text-lg">Neuen Mieter anlegen</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="Vor- und Nachname"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">E-Mail</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Telefon</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={form.telefon}
|
||||
onChange={(e) => setForm({ ...form, telefon: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="04823 12345"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Objekt *</label>
|
||||
<select
|
||||
value={form.objekt_id}
|
||||
onChange={(e) => setForm({ ...form, objekt_id: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
>
|
||||
<option value="">-- Objekt auswählen --</option>
|
||||
{objekte.map(o => (
|
||||
<option key={o.id} value={o.id}>{o.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse (für Abrechnung) *</label>
|
||||
<textarea
|
||||
value={form.adresse}
|
||||
onChange={(e) => setForm({ ...form, adresse: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
rows={2}
|
||||
placeholder="Musterstraße 12, 25554 Wilster"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Wohnfläche (m²) *</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.wohnflaeche_qm}
|
||||
onChange={(e) => setForm({ ...form, wohnflaeche_qm: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="85.00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kaltmiete (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.kaltmiete}
|
||||
onChange={(e) => setForm({ ...form, kaltmiete: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="800.00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">NK-Vorauszahlung (€/Monat)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.nebenkosten_vorauszahlung}
|
||||
onChange={(e) => setForm({ ...form, nebenkosten_vorauszahlung: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="150.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Vertragsbeginn *</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.vertragsbeginn}
|
||||
onChange={(e) => setForm({ ...form, vertragsbeginn: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Vertragsende (optional)</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.vertragsende}
|
||||
onChange={(e) => setForm({ ...form, vertragsende: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Leer lassen = unbefristeter Vertrag</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={addMieter}
|
||||
disabled={!form.name || !form.objekt_id || !form.wohnflaeche_qm}
|
||||
className="bg-green-600 text-white px-6 py-2 rounded-lg hover:bg-green-700 disabled:bg-gray-400 flex items-center gap-2"
|
||||
>
|
||||
<Save size={20} />
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddForm(false)}
|
||||
className="bg-gray-200 text-gray-700 px-6 py-2 rounded-lg hover:bg-gray-300 flex items-center gap-2"
|
||||
>
|
||||
<X size={20} />
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mieter.length > 0 ? (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Objekt</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Wohnfläche</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">NK-Vorauszahlung</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Vertragszeitraum</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Adresse (Rechnung)</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{mieter.map(m => {
|
||||
const isEditing = editingId === m.id;
|
||||
return (
|
||||
<tr key={m.id}>
|
||||
<td className="px-6 py-4">
|
||||
{isEditing && editingField === 'name' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
className="border rounded px-2 py-1 flex-1"
|
||||
autoFocus
|
||||
/>
|
||||
<button onClick={() => saveField(m.id, 'name')} className="text-green-600">
|
||||
<Save size={16} />
|
||||
</button>
|
||||
<button onClick={cancelEditing} className="text-gray-400">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="font-medium cursor-pointer group flex items-center gap-2"
|
||||
onClick={() => startEditing(m, 'name', m.name)}
|
||||
>
|
||||
{m.name}
|
||||
<Edit2 size={14} className="text-gray-300 group-hover:text-blue-400 opacity-0 group-hover:opacity-100" />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm">
|
||||
{m.mietvertrag ? (
|
||||
<div className="flex items-center gap-1 text-gray-700">
|
||||
<Building2 size={14} className="text-gray-400" />
|
||||
{objekte.find(o => o.id === m.mietvertrag.objekt_id)?.name || 'Objekt ' + m.mietvertrag.objekt_id}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400 italic">Kein Objekt</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm">
|
||||
{m.mietvertrag ? (
|
||||
<span className="text-gray-700">{(() => {
|
||||
const val = parseFloat(m.mietvertrag?.wohnflaeche_qm);
|
||||
return isNaN(val) ? '-' : val.toFixed(2) + ' m²';
|
||||
})()}</span>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm">
|
||||
{m.mietvertrag ? (
|
||||
<span className="text-gray-700">{(() => {
|
||||
const val = parseFloat(m.mietvertrag?.nebenkosten_vorauszahlung);
|
||||
return isNaN(val) ? '-' : val.toFixed(2) + ' €/Monat';
|
||||
})()}</span>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm">
|
||||
{m.mietvertrag?.vertragsbeginn ? (
|
||||
<div className="text-gray-700">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar size={12} className="text-gray-400" />
|
||||
{new Date(m.mietvertrag.vertragsbeginn).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
{m.mietvertrag.vertragsende && (
|
||||
<div className="text-xs text-orange-600 mt-1">
|
||||
bis {new Date(m.mietvertrag.vertragsende).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
)}
|
||||
{!m.mietvertrag.vertragsende && (
|
||||
<div className="text-xs text-emerald-600 mt-1">unbefristet</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm">
|
||||
{isEditing && editingField === 'adresse' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<textarea
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
className="border rounded px-2 py-1 flex-1 text-sm"
|
||||
rows={2}
|
||||
autoFocus
|
||||
/>
|
||||
<button onClick={() => saveField(m.id, 'adresse')} className="text-green-600">
|
||||
<Save size={16} />
|
||||
</button>
|
||||
<button onClick={cancelEditing} className="text-gray-400">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="text-gray-600 cursor-pointer group flex items-start gap-2 whitespace-pre-line"
|
||||
onClick={() => startEditing(m, 'adresse', m.adresse)}
|
||||
>
|
||||
<span>{m.adresse || '-'}</span>
|
||||
<Edit2 size={14} className="text-gray-300 group-hover:text-blue-400 opacity-0 group-hover:opacity-100 mt-1" />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => openEditModal(m)}
|
||||
className="text-blue-400 hover:text-blue-600"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteMieter(m.id)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center text-gray-500">
|
||||
<Users size={48} className="mx-auto mb-4 text-gray-300" />
|
||||
<p>Noch keine Mieter angelegt.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
{showEditModal && editingMieter && (
|
||||
<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 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Edit2 size={20} className="text-blue-600" />
|
||||
Mieter bearbeiten
|
||||
</h3>
|
||||
<button onClick={closeEditModal} className="text-gray-400 hover:text-gray-600">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Persönliche Daten */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-500 uppercase mb-3">Persönliche Daten</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">E-Mail</label>
|
||||
<input
|
||||
type="email"
|
||||
value={editForm.email}
|
||||
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Telefon</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={editForm.telefon}
|
||||
onChange={(e) => setEditForm({ ...editForm, telefon: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse (für Abrechnung)</label>
|
||||
<textarea
|
||||
value={editForm.adresse}
|
||||
onChange={(e) => setEditForm({ ...editForm, adresse: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mietvertragsdaten */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h4 className="text-sm font-semibold text-gray-500 uppercase mb-3 flex items-center gap-2">
|
||||
<Building2 size={16} />
|
||||
Mietvertrag
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Objekt *</label>
|
||||
<select
|
||||
value={editForm.objekt_id}
|
||||
onChange={(e) => setEditForm({ ...editForm, objekt_id: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
>
|
||||
<option value="">-- Objekt auswählen --</option>
|
||||
{objekte.map(o => (
|
||||
<option key={o.id} value={o.id}>{o.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Vertragsbeginn *</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editForm.vertragsbeginn}
|
||||
onChange={(e) => setEditForm({ ...editForm, vertragsbeginn: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Vertragsende (optional)</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editForm.vertragsende}
|
||||
onChange={(e) => setEditForm({ ...editForm, vertragsende: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Leer lassen = unbefristeter Vertrag</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Wohnfläche (m²)</label>
|
||||
<div className="w-full border border-gray-200 bg-gray-50 rounded px-3 py-2 text-gray-600">
|
||||
{editForm.objekt_id
|
||||
? (() => {
|
||||
const obj = objekte.find(o => o.id.toString() === editForm.objekt_id);
|
||||
const val = parseFloat(obj?.wohnflaeche_qm);
|
||||
return isNaN(val) ? '-' : val.toFixed(2) + ' m² (vom Objekt)';
|
||||
})()
|
||||
: '-'
|
||||
}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">Wird automatisch vom Objekt übernommen</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kaltmiete (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={editForm.kaltmiete}
|
||||
onChange={(e) => setEditForm({ ...editForm, kaltmiete: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">NK-Vorauszahlung (€/Monat)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={editForm.nebenkosten_vorauszahlung}
|
||||
onChange={(e) => setEditForm({ ...editForm, nebenkosten_vorauszahlung: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-gray-200 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={closeEditModal}
|
||||
className="bg-gray-200 text-gray-700 px-6 py-2 rounded-lg hover:bg-gray-300"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={saveEditModal}
|
||||
disabled={!editForm.name || !editForm.objekt_id}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 flex items-center gap-2"
|
||||
>
|
||||
<Save size={20} />
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Calculator, FileText, Download, Building2, Euro, Users, CheckCircle, AlertCircle, Info, TrendingUp } from 'lucide-react';
|
||||
import { nebenkostenabrechnungAPI, objekteAPI, mieterAPI } from '../../api-nebenkosten';
|
||||
import VermietungsBilanz from './VermietungsBilanz';
|
||||
|
||||
export default function Nebenkostenabrechnung() {
|
||||
const [objekte, setObjekte] = useState([]);
|
||||
const [alleMieter, setAlleMieter] = useState([]);
|
||||
const [abrechnungen, setAbrechnungen] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [aktiverTab, setAktiverTab] = useState('abrechnung');
|
||||
|
||||
const [selectedObjekt, setSelectedObjekt] = useState('');
|
||||
const [selectedMieter, setSelectedMieter] = useState([]); // Array für mehrere Mieter
|
||||
const [alleMieterAusgewaehlt, setAlleMieterAusgewaehlt] = useState(true);
|
||||
const [jahr, setJahr] = useState(new Date().getFullYear());
|
||||
const [zeitraumVon, setZeitraumVon] = useState(`${new Date().getFullYear()}-01-01`);
|
||||
const [zeitraumBis, setZeitraumBis] = useState(`${new Date().getFullYear()}-12-31`);
|
||||
const [vorschau, setVorschau] = useState(null);
|
||||
const [showVorschau, setShowVorschau] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [objData, mieterData, abrData] = await Promise.all([
|
||||
objekteAPI.getAll(),
|
||||
mieterAPI.getAll(),
|
||||
nebenkostenabrechnungAPI.getAll()
|
||||
]);
|
||||
setObjekte(objData);
|
||||
setAlleMieter(mieterData);
|
||||
setAbrechnungen(abrData);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden: ' + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Mieter für das ausgewählte Objekt filtern
|
||||
const getObjektMieter = () => {
|
||||
if (!selectedObjekt) return [];
|
||||
// Mieter die in diesem Objekt wohnen (via Mietvertrag)
|
||||
return alleMieter.filter(m => {
|
||||
// Prüfe ob Mieter im ausgewählten Objekt einen Vertrag hat
|
||||
return m.mietvertraege?.some(mv => mv.objekt_id === selectedObjekt) ||
|
||||
vorschau?.mieter?.some(vm => vm.mieter_id === m.id);
|
||||
});
|
||||
};
|
||||
|
||||
const berechneVorschau = async () => {
|
||||
if (!selectedObjekt) {
|
||||
setError('Bitte ein Objekt auswählen');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
const data = await nebenkostenabrechnungAPI.vorschau({
|
||||
objektId: selectedObjekt,
|
||||
jahr: jahr,
|
||||
zeitraumVon: zeitraumVon,
|
||||
zeitraumBis: zeitraumBis,
|
||||
// Optional: nur bestimmte Mieter
|
||||
mieterIds: alleMieterAusgewaehlt ? null : selectedMieter
|
||||
});
|
||||
setVorschau(data);
|
||||
setShowVorschau(true);
|
||||
} catch (err) {
|
||||
setError('Fehler bei der Berechnung: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const speichereAbrechnung = async (istEntwurf = false) => {
|
||||
if (!vorschau) return;
|
||||
|
||||
try {
|
||||
await nebenkostenabrechnungAPI.create({
|
||||
objekt_id: selectedObjekt,
|
||||
jahr: jahr,
|
||||
zeitraum_von: zeitraumVon,
|
||||
zeitraum_bis: zeitraumBis,
|
||||
ist_entwurf: istEntwurf,
|
||||
berechnungen: vorschau.berechnungen,
|
||||
// Optional: nur bestimmte Mieter
|
||||
mieter_ids: alleMieterAusgewaehlt ? null : selectedMieter
|
||||
});
|
||||
|
||||
await loadData();
|
||||
setVorschau(null);
|
||||
setShowVorschau(false);
|
||||
setSelectedMieter([]);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Speichern: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadPDF = async (id, istEntwurf = false) => {
|
||||
try {
|
||||
await nebenkostenabrechnungAPI.downloadPDF(id, istEntwurf);
|
||||
} catch (err) {
|
||||
setError('PDF-Fehler: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (betrag) => {
|
||||
return parseFloat(betrag || 0).toLocaleString('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
});
|
||||
};
|
||||
|
||||
const getObjektName = (objektId) => {
|
||||
const obj = objekte.find(o => o.id === objektId);
|
||||
return obj ? obj.name : 'Unbekanntes Objekt';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Lade...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const objektMieter = getObjektMieter();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-2 text-red-700">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-auto">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-2 text-blue-700">
|
||||
<Info size={20} className="mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<strong>Tipp:</strong> Kosten werden unter <strong>„Objekte"</strong> verwaltet.
|
||||
Wähle ein Objekt → Kosten für Müll, Grundsteuer, Versicherung etc. erfassen.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="bg-white rounded-lg shadow p-2">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setAktiverTab('abrechnung')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
aktiverTab === 'abrechnung'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Calculator size={20} />
|
||||
NK-Abrechnung
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAktiverTab('bilanz')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
aktiverTab === 'bilanz'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<TrendingUp size={20} />
|
||||
Vermietungs-Bilanz
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Inhalte */}
|
||||
{aktiverTab === 'bilanz' ? (
|
||||
<VermietungsBilanz />
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2 mb-6">
|
||||
<Calculator size={24} />
|
||||
Neue Abrechnung erstellen
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Objekt *</label>
|
||||
<select
|
||||
value={selectedObjekt}
|
||||
onChange={(e) => {
|
||||
setSelectedObjekt(e.target.value);
|
||||
setSelectedMieter([]);
|
||||
setVorschau(null);
|
||||
setShowVorschau(false);
|
||||
}}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
>
|
||||
<option value="">Bitte wählen...</option>
|
||||
{objekte.map(obj => (
|
||||
<option key={obj.id} value={obj.id}>{obj.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Jahr</label>
|
||||
<input
|
||||
type="number"
|
||||
value={jahr}
|
||||
onChange={(e) => {
|
||||
const newJahr = parseInt(e.target.value);
|
||||
setJahr(newJahr);
|
||||
setZeitraumVon(`${newJahr}-01-01`);
|
||||
setZeitraumBis(`${newJahr}-12-31`);
|
||||
}}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-1">
|
||||
<Users size={14} />
|
||||
Mieter-Auswahl
|
||||
</label>
|
||||
<select
|
||||
value={alleMieterAusgewaehlt ? 'alle' : 'auswahl'}
|
||||
onChange={(e) => {
|
||||
setAlleMieterAusgewaehlt(e.target.value === 'alle');
|
||||
if (e.target.value === 'alle') {
|
||||
setSelectedMieter([]);
|
||||
}
|
||||
}}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
disabled={!selectedObjekt}
|
||||
>
|
||||
<option value="alle">Alle Mieter</option>
|
||||
<option value="auswahl">Auswahl...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!alleMieterAusgewaehlt && selectedObjekt && (
|
||||
<div className="mt-4 p-4 bg-gray-50 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Mieter auswählen (mehrere möglich):</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{objektMieter.length > 0 ? objektMieter.map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => {
|
||||
if (selectedMieter.includes(m.id)) {
|
||||
setSelectedMieter(selectedMieter.filter(id => id !== m.id));
|
||||
} else {
|
||||
setSelectedMieter([...selectedMieter, m.id]);
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-1 rounded-full text-sm flex items-center gap-1 transition-colors ${
|
||||
selectedMieter.includes(m.id)
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
{selectedMieter.includes(m.id) && <CheckCircle size={14} />}
|
||||
{m.name}
|
||||
</button>
|
||||
)) : (
|
||||
<span className="text-sm text-gray-500">
|
||||
Keine Mieter gefunden. Erstelle zuerst Mieter und verknüpfe sie mit diesem Objekt.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Zeitraum von</label>
|
||||
<input
|
||||
type="date"
|
||||
value={zeitraumVon}
|
||||
onChange={(e) => setZeitraumVon(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Zeitraum bis</label>
|
||||
<input
|
||||
type="date"
|
||||
value={zeitraumBis}
|
||||
onChange={(e) => setZeitraumBis(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={berechneVorschau}
|
||||
disabled={!selectedObjekt || (!alleMieterAusgewaehlt && selectedMieter.length === 0)}
|
||||
className="mt-4 bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 flex items-center gap-2"
|
||||
>
|
||||
<Calculator size={20} />
|
||||
Abrechnung berechnen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showVorschau && vorschau && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
Vorschau: {vorschau.objekt?.name || getObjektName(selectedObjekt)} {vorschau.jahr}
|
||||
</h3>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">Gesamtkosten</span>
|
||||
<p className="font-semibold text-lg">{formatCurrency(vorschau.gesamt_kosten)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">Gesamtfläche</span>
|
||||
<p className="font-semibold text-lg">{parseFloat(vorschau.gesamt_flaeche).toFixed(1)} m²</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">Anzahl Mieter</span>
|
||||
<p className="font-semibold text-lg">{vorschau.mieter?.length || vorschau.berechnungen?.length || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">Tage</span>
|
||||
<p className="font-semibold text-lg">{vorschau.tage_gesamt}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="font-medium mb-3">Kostenübersicht:</h4>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="px-4 py-2 text-left">Kategorie</th>
|
||||
<th className="px-4 py-2 text-left">Bezeichnung</th>
|
||||
<th className="px-4 py-2 text-right">Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vorschau.kosten?.map((k) => (
|
||||
<tr key={k.id} className="border-b">
|
||||
<td className="px-4 py-2">{k.kategorie}</td>
|
||||
<td className="px-4 py-2">{k.bezeichnung}</td>
|
||||
<td className="px-4 py-2 text-right">{formatCurrency(k.betrag)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h4 className="font-medium mb-3">Berechnung pro Mieter:</h4>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="px-4 py-2 text-left">Mieter</th>
|
||||
<th className="px-4 py-2 text-right">Zeitraum</th>
|
||||
<th className="px-4 py-2 text-right">Anteil m²</th>
|
||||
<th className="px-4 py-2 text-right">Anteil Kosten</th>
|
||||
<th className="px-4 py-2 text-right">Vorauszahlungen</th>
|
||||
<th className="px-4 py-2 text-right">Ergebnis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vorschau.berechnungen?.map((b) => (
|
||||
<tr key={b.mieter_id} className="border-b">
|
||||
<td className="px-4 py-2 font-medium">{b.mieter_name}</td>
|
||||
<td className="px-4 py-2 text-right text-sm text-gray-600">
|
||||
{new Date(b.zeitraum_von).toLocaleDateString('de-DE')} - {new Date(b.zeitraum_bis).toLocaleDateString('de-DE')}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">{parseFloat(b.anteil_qm).toFixed(1)} m²</td>
|
||||
<td className="px-4 py-2 text-right">{formatCurrency(b.anteil_kosten)}</td>
|
||||
<td className="px-4 py-2 text-right">{formatCurrency(b.summe_vorauszahlungen)}</td>
|
||||
<td className={`px-4 py-2 text-right font-semibold ${
|
||||
b.ergebnis > 0 ? 'text-red-600' : b.ergebnis < 0 ? 'text-green-600' : ''
|
||||
}`}>
|
||||
{b.ergebnis > 0 ? '+' : ''}{formatCurrency(b.ergebnis)}
|
||||
<br />
|
||||
<span className="text-xs font-normal">
|
||||
{b.ergebnis > 0 ? 'Nachzahlung' : b.ergebnis < 0 ? 'Gutschrift' : 'Ausgeglichen'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => speichereAbrechnung(true)}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-2"
|
||||
>
|
||||
<FileText size={20} />
|
||||
Als Entwurf speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={() => speichereAbrechnung(false)}
|
||||
className="bg-green-600 text-white px-6 py-2 rounded-lg hover:bg-green-700 flex items-center gap-2"
|
||||
>
|
||||
<CheckCircle size={20} />
|
||||
Abrechnung finalisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{abrechnungen.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Gespeicherte Abrechnungen</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{abrechnungen.map((abr) => (
|
||||
<div key={abr.id} className="flex items-center justify-between bg-gray-50 p-4 rounded-lg">
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{getObjektName(abr.objekt_id)} {abr.jahr}
|
||||
{abr.ist_entwurf && (
|
||||
<span className="ml-2 text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded">
|
||||
Entwurf
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date(abr.zeitraum_von).toLocaleDateString('de-DE')} - {' '}
|
||||
{new Date(abr.zeitraum_bis).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => downloadPDF(abr.id, true)}
|
||||
className="text-blue-600 hover:bg-blue-50 px-3 py-1 rounded flex items-center gap-1"
|
||||
>
|
||||
<FileText size={16} />
|
||||
Entwurf-PDF
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadPDF(abr.id, false)}
|
||||
className="bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700 flex items-center gap-1"
|
||||
>
|
||||
<Download size={16} />
|
||||
PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,813 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Building2, Plus, Trash2, Edit2, Save, X, AlertCircle, Wallet, DollarSign, ChevronDown, ChevronUp, Settings, Tag, Euro } from 'lucide-react';
|
||||
import { objekteAPI } from '../../api-nebenkosten';
|
||||
|
||||
const STANDARD_KOSTENSTELLEN = ['Heizung', 'Wasser', 'Strom', 'Müll', 'Versicherung', 'Sonstiges'];
|
||||
|
||||
export default function Objekte() {
|
||||
const [objekte, setObjekte] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [editingField, setEditingField] = useState(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
|
||||
// Kosten State Management
|
||||
const [expandedObjectId, setExpandedObjectId] = useState(null);
|
||||
const [kostenData, setKostenData] = useState({});
|
||||
const [kostenLoading, setKostenLoading] = useState({});
|
||||
const [showAddKostenForm, setShowAddKostenForm] = useState({});
|
||||
const [newKosten, setNewKosten] = useState({});
|
||||
const [jahrFilter, setJahrFilter] = useState({});
|
||||
|
||||
// Kostenstellen State Management
|
||||
const [kostenstellen, setKostenstellen] = useState(() => {
|
||||
// Lade aus localStorage oder nutze Standard
|
||||
const saved = localStorage.getItem('kostenstellen');
|
||||
return saved ? JSON.parse(saved) : [...STANDARD_KOSTENSTELLEN];
|
||||
});
|
||||
const [showKostenstellenModal, setShowKostenstellenModal] = useState(false);
|
||||
const [newKostenstelleName, setNewKostenstelleName] = useState('');
|
||||
|
||||
// Kostenstellen in localStorage speichern
|
||||
useEffect(() => {
|
||||
localStorage.setItem('kostenstellen', JSON.stringify(kostenstellen));
|
||||
}, [kostenstellen]);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
adresse: '',
|
||||
plz: '',
|
||||
ort: '',
|
||||
wohnflaeche_qm: '',
|
||||
bemerkung: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadObjekte();
|
||||
}, []);
|
||||
|
||||
const loadObjekte = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await objekteAPI.getAll();
|
||||
setObjekte(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden: ' + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Kosten für ein Objekt laden
|
||||
const loadKosten = async (objektId) => {
|
||||
const jahr = jahrFilter[objektId] || new Date().getFullYear();
|
||||
try {
|
||||
setKostenLoading(prev => ({ ...prev, [objektId]: true }));
|
||||
const data = await objekteAPI.getKosten(objektId, jahr);
|
||||
setKostenData(prev => ({ ...prev, [objektId]: data }));
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden der Kosten: ' + err.message);
|
||||
} finally {
|
||||
setKostenLoading(prev => ({ ...prev, [objektId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Kosten-Bereich ein-/ausklappen
|
||||
const toggleKosten = (objektId) => {
|
||||
if (expandedObjectId === objektId) {
|
||||
setExpandedObjectId(null);
|
||||
} else {
|
||||
setExpandedObjectId(objektId);
|
||||
loadKosten(objektId);
|
||||
}
|
||||
};
|
||||
|
||||
// Jahr-Filter ändern und Kosten neu laden
|
||||
const handleJahrFilterChange = (objektId, jahr) => {
|
||||
setJahrFilter(prev => ({ ...prev, [objektId]: jahr }));
|
||||
setTimeout(() => loadKosten(objektId), 0);
|
||||
};
|
||||
|
||||
// Neue Kosten hinzufügen
|
||||
const addKosten = async (objektId) => {
|
||||
const kosten = newKosten[objektId];
|
||||
if (!kosten || !kosten.kategorie || !kosten.betrag || !kosten.jahr) return;
|
||||
|
||||
try {
|
||||
await objekteAPI.addKosten(objektId, {
|
||||
kategorie: kosten.kategorie,
|
||||
betrag: parseFloat(kosten.betrag),
|
||||
jahr: parseInt(kosten.jahr)
|
||||
});
|
||||
|
||||
// Formular zurücksetzen
|
||||
setNewKosten(prev => ({ ...prev, [objektId]: { kategorie: '', betrag: '', jahr: '' } }));
|
||||
setShowAddKostenForm(prev => ({ ...prev, [objektId]: false }));
|
||||
|
||||
// Kosten neu laden
|
||||
await loadKosten(objektId);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Hinzufügen: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Kosten löschen
|
||||
const deleteKosten = async (objektId, kostenId) => {
|
||||
if (confirm('Diese Kosten wirklich löschen?')) {
|
||||
try {
|
||||
await objekteAPI.deleteKosten(objektId, kostenId);
|
||||
await loadKosten(objektId);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Löschen: ' + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addObjekt = async () => {
|
||||
if (!form.name || !form.adresse || !form.plz || !form.ort) return;
|
||||
|
||||
try {
|
||||
await objekteAPI.create({
|
||||
name: form.name,
|
||||
adresse: form.adresse,
|
||||
plz: form.plz,
|
||||
ort: form.ort,
|
||||
wohnflaeche_qm: parseFloat(form.wohnflaeche_qm) || 0,
|
||||
bemerkung: form.bemerkung
|
||||
});
|
||||
await loadObjekte();
|
||||
|
||||
setForm({
|
||||
name: '',
|
||||
adresse: '',
|
||||
plz: '',
|
||||
ort: '',
|
||||
wohnflaeche_qm: '',
|
||||
bemerkung: ''
|
||||
});
|
||||
setShowAddForm(false);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Speichern: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const startEditing = (objekt, field, value) => {
|
||||
setEditingId(objekt.id);
|
||||
setEditingField(field);
|
||||
setEditValue(value || '');
|
||||
};
|
||||
|
||||
const saveField = async (id, field) => {
|
||||
if (field === 'plz_ort') {
|
||||
// Spezialfall: PLZ und Ort zusammen
|
||||
await updateObjekt(id, { plz: editValue.plz, ort: editValue.ort });
|
||||
} else {
|
||||
const updates = { [field]: editValue };
|
||||
await updateObjekt(id, updates);
|
||||
}
|
||||
setEditingField(null);
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditingField(null);
|
||||
setEditingId(null);
|
||||
setEditValue('');
|
||||
};
|
||||
|
||||
const updateObjekt = async (id, updates) => {
|
||||
try {
|
||||
const obj = objekte.find(o => o.id === id);
|
||||
const data = {
|
||||
name: updates.name !== undefined ? updates.name : obj.name,
|
||||
adresse: updates.adresse !== undefined ? updates.adresse : obj.adresse,
|
||||
plz: updates.plz !== undefined ? updates.plz : obj.plz,
|
||||
ort: updates.ort !== undefined ? updates.ort : obj.ort,
|
||||
wohnflaeche_qm: updates.wohnflaeche_qm !== undefined ? parseFloat(updates.wohnflaeche_qm) || obj.wohnflaeche_qm : obj.wohnflaeche_qm,
|
||||
bemerkung: updates.bemerkung !== undefined ? updates.bemerkung : obj.bemerkung
|
||||
};
|
||||
|
||||
await objekteAPI.update(id, data);
|
||||
await loadObjekte();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Aktualisieren: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteObjekt = async (id) => {
|
||||
if (confirm('Objekt wirklich loeschen? Alle zugehoerigen Daten werden ebenfalls geloescht.')) {
|
||||
try {
|
||||
await objekteAPI.delete(id);
|
||||
await loadObjekte();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Loeschen: ' + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Hilfsfunktion: Gesamtkosten berechnen
|
||||
const getGesamtkosten = (objektId) => {
|
||||
const kosten = kostenData[objektId] || [];
|
||||
return kosten.reduce((sum, k) => sum + parseFloat(k.betrag || 0), 0);
|
||||
};
|
||||
|
||||
// Hilfsfunktion: Betrag als Währung formatieren
|
||||
const formatCurrency = (betrag) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(betrag || 0);
|
||||
};
|
||||
|
||||
// Kostenstelle hinzufügen
|
||||
const addKostenstelle = () => {
|
||||
const name = newKostenstelleName.trim();
|
||||
if (!name) return;
|
||||
if (kostenstellen.includes(name)) {
|
||||
alert('Diese Kostenstelle existiert bereits!');
|
||||
return;
|
||||
}
|
||||
setKostenstellen([...kostenstellen, name]);
|
||||
setNewKostenstelleName('');
|
||||
};
|
||||
|
||||
// Kostenstelle löschen (nur eigene, nicht Standard)
|
||||
const deleteKostenstelle = (name) => {
|
||||
if (STANDARD_KOSTENSTELLEN.includes(name)) {
|
||||
alert('Standard-Kostenstellen können nicht gelöscht werden!');
|
||||
return;
|
||||
}
|
||||
if (confirm(`Kostenstelle "${name}" wirklich löschen?`)) {
|
||||
setKostenstellen(kostenstellen.filter(k => k !== name));
|
||||
}
|
||||
};
|
||||
|
||||
// Kosten-Bereich rendern
|
||||
const renderKostenBereich = (objekt) => {
|
||||
const isExpanded = expandedObjectId === objekt.id;
|
||||
const kosten = kostenData[objekt.id] || [];
|
||||
const isLoading = kostenLoading[objekt.id];
|
||||
const showForm = showAddKostenForm[objekt.id] || false;
|
||||
const currentJahrFilter = jahrFilter[objekt.id] || new Date().getFullYear();
|
||||
const currentNewKosten = newKosten[objekt.id] || { kategorie: '', betrag: '', jahr: new Date().getFullYear() };
|
||||
|
||||
return (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={() => toggleKosten(objekt.id)}
|
||||
className="w-full flex items-center justify-between text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Wallet size={18} className="text-blue-500" />
|
||||
<span className="font-medium text-gray-700">Kosten</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
({kosten.length} Einträge, Gesamt: {formatCurrency(getGesamtkosten(objekt.id))})
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp size={20} className="text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown size={20} className="text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* Jahr Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Jahr:</span>
|
||||
<select
|
||||
value={currentJahrFilter}
|
||||
onChange={(e) => handleJahrFilterChange(objekt.id, parseInt(e.target.value))}
|
||||
className="border border-gray-300 rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value={2024}>2024</option>
|
||||
<option value={2025}>2025</option>
|
||||
<option value={2026}>2026</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Kosten-Tabelle */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-4 text-gray-500">Lade Kosten...</div>
|
||||
) : kosten.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-400 text-sm">
|
||||
Keine Kosten für {currentJahrFilter} erfasst.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-2 text-gray-600 font-medium">Kategorie</th>
|
||||
<th className="text-right py-2 text-gray-600 font-medium">Betrag</th>
|
||||
<th className="text-center py-2 text-gray-600 font-medium">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{kosten.map((k, index) => (
|
||||
<tr
|
||||
key={k.id}
|
||||
className={index % 2 === 0 ? 'bg-gray-50' : 'bg-white'}
|
||||
>
|
||||
<td className="py-2 px-2">{k.kategorie}</td>
|
||||
<td className="py-2 px-2 text-right font-mono">
|
||||
{formatCurrency(k.betrag)}
|
||||
</td>
|
||||
<td className="py-2 px-2 text-center">
|
||||
<button
|
||||
onClick={() => deleteKosten(objekt.id, k.id)}
|
||||
className="text-red-400 hover:text-red-600 p-1"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-gray-300 font-semibold">
|
||||
<td className="py-2 px-2">Gesamt</td>
|
||||
<td className="py-2 px-2 text-right font-mono">
|
||||
{formatCurrency(getGesamtkosten(objekt.id))}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* "Kosten hinzufügen" Button */}
|
||||
{!showForm && (
|
||||
<button
|
||||
onClick={() => setShowAddKostenForm(prev => ({ ...prev, [objekt.id]: true }))}
|
||||
className="flex items-center gap-2 text-blue-600 hover:text-blue-700 text-sm"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Kosten hinzufügen
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Kosten-Formular */}
|
||||
{showForm && (
|
||||
<div className="bg-gray-50 rounded-lg p-4 space-y-3">
|
||||
<h4 className="font-medium text-sm text-gray-700">Neue Kosten erfassen</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Kategorie</label>
|
||||
<div className="relative">
|
||||
<Tag size={14} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<select
|
||||
value={currentNewKosten.kategorie}
|
||||
onChange={(e) => setNewKosten(prev => ({
|
||||
...prev,
|
||||
[objekt.id]: { ...currentNewKosten, kategorie: e.target.value }
|
||||
}))}
|
||||
className="w-full border border-gray-300 rounded px-2 py-1 pl-7 text-sm"
|
||||
>
|
||||
<option value="">Bitte wählen</option>
|
||||
{kostenstellen.map(kat => (
|
||||
<option key={kat} value={kat}>{kat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Betrag</label>
|
||||
<div className="relative">
|
||||
<Euro size={14} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={currentNewKosten.betrag}
|
||||
onChange={(e) => setNewKosten(prev => ({
|
||||
...prev,
|
||||
[objekt.id]: { ...currentNewKosten, betrag: e.target.value }
|
||||
}))}
|
||||
className="w-full border border-gray-300 rounded px-2 py-1 pl-7 text-sm"
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Jahr</label>
|
||||
<select
|
||||
value={currentNewKosten.jahr}
|
||||
onChange={(e) => setNewKosten(prev => ({
|
||||
...prev,
|
||||
[objekt.id]: { ...currentNewKosten, jahr: parseInt(e.target.value) }
|
||||
}))}
|
||||
className="w-full border border-gray-300 rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value={2024}>2024</option>
|
||||
<option value={2025}>2025</option>
|
||||
<option value={2026}>2026</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => addKosten(objekt.id)}
|
||||
disabled={!currentNewKosten.kategorie || !currentNewKosten.betrag}
|
||||
className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm hover:bg-blue-700 disabled:bg-gray-400 flex items-center gap-1"
|
||||
>
|
||||
<Save size={14} />
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddKostenForm(prev => ({ ...prev, [objekt.id]: false }))}
|
||||
className="bg-gray-200 text-gray-700 px-4 py-1.5 rounded text-sm hover:bg-gray-300 flex items-center gap-1"
|
||||
>
|
||||
<X size={14} />
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Lade Objekte...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Kostenstellen-Verwaltung Modal */}
|
||||
{showKostenstellenModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[80vh] overflow-hidden">
|
||||
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-lg flex items-center gap-2">
|
||||
<Settings size={20} className="text-blue-500" />
|
||||
Kostenstellen verwalten
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowKostenstellenModal(false)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 overflow-y-auto max-h-[50vh]">
|
||||
{/* Liste aller Kostenstellen */}
|
||||
<div className="space-y-2">
|
||||
{kostenstellen.map((kostenstelle) => (
|
||||
<div
|
||||
key={kostenstelle}
|
||||
className="flex items-center justify-between p-2 bg-gray-50 rounded hover:bg-gray-100"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag size={16} className="text-gray-400" />
|
||||
<span>{kostenstelle}</span>
|
||||
{STANDARD_KOSTENSTELLEN.includes(kostenstelle) && (
|
||||
<span className="text-xs text-gray-400">(Standard)</span>
|
||||
)}
|
||||
</div>
|
||||
{!STANDARD_KOSTENSTELLEN.includes(kostenstelle) && (
|
||||
<button
|
||||
onClick={() => deleteKostenstelle(kostenstelle)}
|
||||
className="text-red-400 hover:text-red-600 p-1"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border-t border-gray-200 bg-gray-50">
|
||||
{/* Neue Kostenstelle hinzufügen */}
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Plus size={16} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={newKostenstelleName}
|
||||
onChange={(e) => setNewKostenstelleName(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && addKostenstelle()}
|
||||
placeholder="Neue Kostenstelle..."
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={addKostenstelle}
|
||||
disabled={!newKostenstelleName.trim()}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded text-sm hover:bg-blue-700 disabled:bg-gray-400 flex items-center gap-1"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-2 text-red-700">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-auto">X</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Building2 size={24} />
|
||||
Objekte und Wohnungen
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowKostenstellenModal(true)}
|
||||
className="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 flex items-center gap-2"
|
||||
>
|
||||
<Settings size={20} />
|
||||
Kostenstellen verwalten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-2"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Neues Objekt
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h3 className="font-semibold text-lg">Neues Objekt anlegen</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="z.B. Wilster Wohnung 1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Wohnfläche (m²) *</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.wohnflaeche_qm}
|
||||
onChange={(e) => setForm({ ...form, wohnflaeche_qm: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="85.00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.adresse}
|
||||
onChange={(e) => setForm({ ...form, adresse: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="Musterstrasse 10"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-1/3">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">PLZ *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.plz}
|
||||
onChange={(e) => setForm({ ...form, plz: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="25554"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ort *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.ort}
|
||||
onChange={(e) => setForm({ ...form, ort: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="Wilster"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Bemerkung</label>
|
||||
<textarea
|
||||
value={form.bemerkung}
|
||||
onChange={(e) => setForm({ ...form, bemerkung: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={addObjekt}
|
||||
disabled={!form.name || !form.adresse || !form.plz || !form.ort || !form.wohnflaeche_qm}
|
||||
className="bg-green-600 text-white px-6 py-2 rounded-lg hover:bg-green-700 disabled:bg-gray-400 flex items-center gap-2"
|
||||
>
|
||||
<Save size={20} />
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddForm(false)}
|
||||
className="bg-gray-200 text-gray-700 px-6 py-2 rounded-lg hover:bg-gray-300 flex items-center gap-2"
|
||||
>
|
||||
<X size={20} />
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{objekte.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{objekte.map(objekt => {
|
||||
const isEditing = editingId === objekt.id;
|
||||
|
||||
return (
|
||||
<div key={objekt.id} className="bg-white rounded-lg shadow p-5">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex-1">
|
||||
{isEditing && editingField === 'name' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-sm font-semibold flex-1"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveField(objekt.id, 'name')}
|
||||
className="text-green-600 hover:text-green-800"
|
||||
>
|
||||
<Save size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEditing}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 cursor-pointer group" onClick={() => startEditing(objekt, 'name', objekt.name)}>
|
||||
<h3 className="font-semibold text-lg">{objekt.name}</h3>
|
||||
<Edit2 size={14} className="text-gray-300 group-hover:text-blue-400 opacity-0 group-hover:opacity-100 transition-all" />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-gray-600">
|
||||
{isEditing && editingField === 'adresse' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-sm flex-1"
|
||||
autoFocus
|
||||
/>
|
||||
<button onClick={() => saveField(objekt.id, 'adresse')} className="text-green-600 hover:text-green-800">
|
||||
<Save size={16} />
|
||||
</button>
|
||||
<button onClick={cancelEditing} className="text-gray-400 hover:text-gray-600">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 cursor-pointer group" onClick={() => startEditing(objekt, 'adresse', objekt.adresse)}>
|
||||
{objekt.adresse}
|
||||
<Edit2 size={14} className="text-gray-300 group-hover:text-blue-400 opacity-0 group-hover:opacity-100 transition-all" />
|
||||
</div>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{isEditing && editingField === 'plz_ort' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editValue.plz || ''}
|
||||
onChange={(e) => setEditValue({...editValue, plz: e.target.value})}
|
||||
className="border rounded px-2 py-1 text-sm w-20"
|
||||
placeholder="PLZ"
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editValue.ort || ''}
|
||||
onChange={(e) => setEditValue({...editValue, ort: e.target.value})}
|
||||
className="border rounded px-2 py-1 text-sm flex-1"
|
||||
placeholder="Ort"
|
||||
/>
|
||||
<button onClick={() => saveField(objekt.id, 'plz_ort')} className="text-green-600 hover:text-green-800">
|
||||
<Save size={16} />
|
||||
</button>
|
||||
<button onClick={cancelEditing} className="text-gray-400 hover:text-gray-600">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 cursor-pointer group" onClick={() => startEditing(objekt, 'plz_ort', {plz: objekt.plz, ort: objekt.ort})}>
|
||||
{objekt.plz} {objekt.ort}
|
||||
<Edit2 size={14} className="text-gray-300 group-hover:text-blue-400 opacity-0 group-hover:opacity-100 transition-all" />
|
||||
</div>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => startEditing(objekt, 'name', objekt.name)}
|
||||
className="text-blue-400 hover:text-blue-600 p-1"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteObjekt(objekt.id)}
|
||||
className="text-red-400 hover:text-red-600 p-1"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<span className="text-gray-500 text-sm">Wohnfläche:</span>
|
||||
{isEditing && editingField === 'wohnflaeche_qm' ? (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-sm w-24"
|
||||
autoFocus
|
||||
/>
|
||||
<span className="text-sm text-gray-500">m²</span>
|
||||
<button
|
||||
onClick={() => saveField(objekt.id, 'wohnflaeche_qm')}
|
||||
className="text-green-600 hover:text-green-800"
|
||||
>
|
||||
<Save size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEditing}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer group"
|
||||
onClick={() => startEditing(objekt, 'wohnflaeche_qm', objekt.wohnflaeche_qm)}
|
||||
>
|
||||
<p className="font-medium">{parseFloat(objekt.wohnflaeche_qm || 0).toFixed(1)} m²</p>
|
||||
<Edit2 size={14} className="text-gray-300 group-hover:text-blue-400 opacity-0 group-hover:opacity-100 transition-all" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{objekt.bemerkung && (
|
||||
<p className="text-sm text-gray-500 mt-3 bg-gray-50 p-2 rounded">
|
||||
{objekt.bemerkung}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Kosten-Bereich */}
|
||||
{renderKostenBereich(objekt)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center text-gray-500">
|
||||
<Building2 size={48} className="mx-auto mb-4 text-gray-300" />
|
||||
<p>Noch keine Objekte angelegt.</p>
|
||||
<p className="text-sm mt-2">Erstellen Sie Ihr erstes Objekt oben.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BarChart3, TrendingUp, TrendingDown, Wallet, AlertCircle, X, Building2, Euro, PieChart, ChevronDown, ChevronUp, Receipt } from 'lucide-react';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
export default function VermietungsBilanz() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedJahr, setSelectedJahr] = useState(2025);
|
||||
const [expandedObjekte, setExpandedObjekte] = useState({});
|
||||
const [bilanzData, setBilanzData] = useState({
|
||||
objekte: [],
|
||||
gesamt: {
|
||||
einnahmen: 0,
|
||||
ausgaben: 0,
|
||||
bilanz: 0,
|
||||
rendite: 0
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadBilanzData();
|
||||
}, [selectedJahr]);
|
||||
|
||||
const loadBilanzData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${API_URL}/vermietung/bilanz?jahr=${selectedJahr}`);
|
||||
|
||||
if (!response.ok) {
|
||||
setError('Fehler beim Laden der Bilanzdaten');
|
||||
setBilanzData({
|
||||
objekte: [],
|
||||
gesamt: { einnahmen: 0, ausgaben: 0, bilanz: 0, rendite: 0 }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setBilanzData(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden: ' + err.message);
|
||||
setBilanzData({
|
||||
objekte: [],
|
||||
gesamt: { einnahmen: 0, ausgaben: 0, bilanz: 0, rendite: 0 }
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleObjekt = (objektId) => {
|
||||
setExpandedObjekte(prev => ({
|
||||
...prev,
|
||||
[objektId]: !prev[objektId]
|
||||
}));
|
||||
};
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
return (parseFloat(value) || 0).toLocaleString('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
maximumFractionDigits: 0
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Lade Vermietungs-Bilanz...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-blue-600 to-blue-800 rounded-lg p-6 text-white">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<BarChart3 size={28} />
|
||||
Vermietungs-Bilanz
|
||||
</h2>
|
||||
<p className="text-blue-200 mt-1">Einnahmen vs. Ausgaben pro Objekt</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={selectedJahr}
|
||||
onChange={(e) => setSelectedJahr(parseInt(e.target.value))}
|
||||
className="bg-white/10 border border-white/20 rounded-lg px-4 py-2 text-white"
|
||||
>
|
||||
<option value={2024} className="text-gray-900">2024</option>
|
||||
<option value={2025} className="text-gray-900">2025</option>
|
||||
<option value={2026} className="text-gray-900">2026</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-2 text-red-700">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-auto">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gesamt-Übersicht Karten */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-emerald-500">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-emerald-100 p-2 rounded-lg">
|
||||
<Wallet className="text-emerald-600" size={24} />
|
||||
</div>
|
||||
<span className="text-gray-600">Gesamteinnahmen</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-emerald-700">
|
||||
{formatCurrency(bilanzData.gesamt?.einnahmen || 0)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Kaltmieten aller Objekte</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-rose-500">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-rose-100 p-2 rounded-lg">
|
||||
<Receipt className="text-rose-600" size={24} />
|
||||
</div>
|
||||
<span className="text-gray-600">Gesamtausgaben</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-rose-700">
|
||||
{formatCurrency(bilanzData.gesamt?.ausgaben || 0)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Objektkosten (NK + Sonstige)</p>
|
||||
</div>
|
||||
|
||||
<div className={`bg-white rounded-lg shadow p-6 border-l-4 ${(bilanzData.gesamt?.bilanz || 0) >= 0 ? 'border-blue-500' : 'border-amber-500'}`}>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className={`p-2 rounded-lg ${(bilanzData.gesamt?.bilanz || 0) >= 0 ? 'bg-blue-100' : 'bg-amber-100'}`}>
|
||||
{(bilanzData.gesamt?.bilanz || 0) >= 0
|
||||
? <TrendingUp className="text-blue-600" size={24} />
|
||||
: <TrendingDown className="text-amber-600" size={24} />
|
||||
}
|
||||
</div>
|
||||
<span className="text-gray-600">Jahresbilanz</span>
|
||||
</div>
|
||||
<p className={`text-2xl font-bold ${(bilanzData.gesamt?.bilanz || 0) >= 0 ? 'text-blue-700' : 'text-amber-700'}`}>
|
||||
{formatCurrency(bilanzData.gesamt?.bilanz || 0)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Rendite: {bilanzData.gesamt?.rendite || 0}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Objekt-Details Accordion */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center gap-2">
|
||||
<Building2 className="text-blue-600" size={20} />
|
||||
<h3 className="text-lg font-semibold">Bilanz pro Objekt - {selectedJahr}</h3>
|
||||
<span className="ml-auto text-sm text-gray-500">
|
||||
{bilanzData.objekte?.length || 0} Objekte
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(bilanzData.objekte?.length || 0) === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<PieChart size={48} className="mx-auto mb-4 text-gray-300" />
|
||||
<p>Keine Objekte vorhanden.</p>
|
||||
<p className="text-sm mt-2">Füge zuerst Objekte und Mieter im Vermietungsbereich hinzu.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{bilanzData.objekte.map((objekt) => {
|
||||
const isExpanded = expandedObjekte[objekt.id];
|
||||
const hasDetails = (objekt.details?.einnahmen?.length > 0) || (objekt.details?.ausgaben?.length > 0);
|
||||
|
||||
return (
|
||||
<div key={objekt.id} className="group">
|
||||
{/* Header Row - Always Visible */}
|
||||
<div
|
||||
onClick={() => hasDetails && toggleObjekt(objekt.id)}
|
||||
className={`px-6 py-4 flex items-center justify-between transition-colors ${hasDetails ? 'cursor-pointer hover:bg-gray-50' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
{hasDetails && (
|
||||
<button className="p-1 rounded-full hover:bg-gray-200 transition-colors">
|
||||
{isExpanded ? <ChevronUp size={20} className="text-gray-500" /> : <ChevronDown size={20} className="text-gray-500" />}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-gray-900">{objekt.name}</div>
|
||||
{objekt.adresse && (
|
||||
<div className="text-sm text-gray-500">{objekt.adresse}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="flex items-center gap-8 text-right">
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Einnahmen</div>
|
||||
<div className="font-medium text-emerald-600">{formatCurrency(objekt.einnahmen)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Ausgaben</div>
|
||||
<div className="font-medium text-rose-600">{formatCurrency(objekt.ausgaben)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Bilanz</div>
|
||||
<div className={`font-bold ${objekt.bilanz >= 0 ? 'text-blue-600' : 'text-amber-600'}`}>
|
||||
{objekt.bilanz >= 0 ? '+' : ''}{formatCurrency(objekt.bilanz)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Rendite</div>
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-sm font-medium ${
|
||||
objekt.rendite >= 0
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: 'bg-rose-100 text-rose-700'
|
||||
}`}>
|
||||
{objekt.rendite >= 0 ? <TrendingUp size={14} /> : <TrendingDown size={14} />}
|
||||
{objekt.rendite}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{isExpanded && hasDetails && (
|
||||
<div className="px-6 pb-6 bg-gray-50/50 border-t border-gray-100">
|
||||
<div className="pt-4 space-y-6">
|
||||
|
||||
{/* EINNAHMEN-DETAILS */}
|
||||
{objekt.details?.einnahmen?.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Wallet size={18} className="text-emerald-600" />
|
||||
<h4 className="font-semibold text-gray-800">EINNAHMEN-DETAILS</h4>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="text-left py-2 px-4 font-medium text-gray-700">Mieter</th>
|
||||
<th className="text-left py-2 px-4 font-medium text-gray-700">Zeitraum</th>
|
||||
<th className="text-right py-2 px-4 font-medium text-gray-700">Monate</th>
|
||||
<th className="text-right py-2 px-4 font-medium text-gray-700">Kaltmiete/Monat</th>
|
||||
<th className="text-right py-2 px-4 font-medium text-gray-700">Summe</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{objekt.details.einnahmen.map((einnahme, idx) => (
|
||||
<tr key={idx} className="hover:bg-gray-50">
|
||||
<td className="py-2 px-4 text-gray-900">{einnahme.mieter}</td>
|
||||
<td className="py-2 px-4 text-gray-600">{einnahme.zeitraum}</td>
|
||||
<td className="py-2 px-4 text-right text-gray-600">{einnahme.monate}</td>
|
||||
<td className="py-2 px-4 text-right text-gray-600">{formatCurrency(einnahme.kaltmiete)}</td>
|
||||
<td className="py-2 px-4 text-right font-medium text-emerald-600">{formatCurrency(einnahme.summe)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="bg-gray-50 font-semibold">
|
||||
<tr>
|
||||
<td className="py-2 px-4 text-gray-800" colSpan="4">Summe Einnahmen</td>
|
||||
<td className="py-2 px-4 text-right text-emerald-700">
|
||||
{formatCurrency(objekt.einnahmen)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AUSGABEN-DETAILS */}
|
||||
{objekt.details?.ausgaben?.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Receipt size={18} className="text-rose-600" />
|
||||
<h4 className="font-semibold text-gray-800">AUSGABEN-DETAILS</h4>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="text-left py-2 px-4 font-medium text-gray-700">Kategorie</th>
|
||||
<th className="text-left py-2 px-4 font-medium text-gray-700">Bezeichnung</th>
|
||||
<th className="text-right py-2 px-4 font-medium text-gray-700">Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{objekt.details.ausgaben.map((ausgabe, idx) => (
|
||||
<tr key={idx} className="hover:bg-gray-50">
|
||||
<td className="py-2 px-4 text-gray-900">{ausgabe.kategorie}</td>
|
||||
<td className="py-2 px-4 text-gray-600">{ausgabe.bezeichnung || '-'}</td>
|
||||
<td className="py-2 px-4 text-right font-medium text-rose-600">{formatCurrency(ausgabe.betrag)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="bg-gray-50 font-semibold">
|
||||
<tr>
|
||||
<td className="py-2 px-4 text-gray-800" colSpan="2">Summe Ausgaben</td>
|
||||
<td className="py-2 px-4 text-right text-rose-700">
|
||||
{formatCurrency(objekt.ausgaben)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BERECHNUNG */}
|
||||
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200">
|
||||
<h4 className="font-semibold text-blue-800 mb-2">Berechnung:</h4>
|
||||
<div className="font-mono text-sm text-blue-700 space-y-1">
|
||||
<div>Einnahmen: {formatCurrency(objekt.einnahmen)}</div>
|
||||
<div className="text-rose-600">- Ausgaben: {formatCurrency(objekt.ausgaben)}</div>
|
||||
<div className="border-t border-blue-300 pt-1 font-bold">
|
||||
= Bilanz: {objekt.bilanz >= 0 ? '+' : ''}{formatCurrency(objekt.bilanz)}
|
||||
{objekt.einnahmen > 0 && (
|
||||
<span className="ml-2 text-emerald-600">
|
||||
({objekt.rendite >= 0 ? '+' : ''}{objekt.rendite}% Rendite)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hinweis */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Euro className="text-blue-600 flex-shrink-0 mt-0.5" size={18} />
|
||||
<div>
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Berechnung:</strong> Einnahmen = Summe der Kaltmieten aller Mieter für das ausgewählte Jahr.
|
||||
Ausgaben = Summe aller Objektkosten (Nebenkosten-Vorauszahlungen und sonstige Kosten).
|
||||
Die Bilanz zeigt den reinen Cashflow aus Vermietung vor Steuern und Finanzierungskosten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [needsSetup, setNeedsSetup] = useState(false);
|
||||
|
||||
// Token aus localStorage laden
|
||||
const getToken = () => localStorage.getItem('auth_token');
|
||||
const saveToken = (token) => localStorage.setItem('auth_token', token);
|
||||
const removeToken = () => localStorage.removeItem('auth_token');
|
||||
|
||||
// User-Daten beim Start laden
|
||||
useEffect(() => {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
fetchUser(token);
|
||||
} else {
|
||||
checkSetup();
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function fetchUser(token) {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUser(data.user);
|
||||
setNeedsSetup(false);
|
||||
} else {
|
||||
removeToken();
|
||||
setUser(null);
|
||||
checkSetup();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fetch user error:', error);
|
||||
removeToken();
|
||||
setUser(null);
|
||||
checkSetup();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkSetup() {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/auth/me`);
|
||||
if (res.status === 401) {
|
||||
// Kein User eingeloggt, prüfen ob Setup nötig
|
||||
const setupRes = await fetch(`${API_URL}/auth/setup`, { method: 'POST' });
|
||||
if (setupRes.status === 400) {
|
||||
// Setup bereits abgeschlossen, Login erforderlich
|
||||
setNeedsSetup(false);
|
||||
} else if (setupRes.status === 201) {
|
||||
// Setup gerade erst abgeschlossen
|
||||
const data = await setupRes.json();
|
||||
saveToken(data.token);
|
||||
setUser(data.user);
|
||||
setNeedsSetup(false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Check setup error:', error);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(`${API_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Login fehlgeschlagen');
|
||||
}
|
||||
|
||||
saveToken(data.token);
|
||||
setUser(data.user);
|
||||
setNeedsSetup(false);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function setup(username, password) {
|
||||
const res = await fetch(`${API_URL}/auth/setup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Setup fehlgeschlagen');
|
||||
}
|
||||
|
||||
saveToken(data.token);
|
||||
setUser(data.user);
|
||||
setNeedsSetup(false);
|
||||
return data;
|
||||
}
|
||||
|
||||
function logout() {
|
||||
fetch(`${API_URL}/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${getToken()}` }
|
||||
}).catch(() => {}); // Ignore errors
|
||||
|
||||
removeToken();
|
||||
setUser(null);
|
||||
}
|
||||
|
||||
const value = {
|
||||
user,
|
||||
loading,
|
||||
needsSetup,
|
||||
isAuthenticated: !!user,
|
||||
isAdmin: user?.role === 'admin',
|
||||
login,
|
||||
setup,
|
||||
logout,
|
||||
getToken
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children }) {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return <div>Lade...</div>;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,46 @@
|
||||
// Initialdaten für SteuerFlow
|
||||
|
||||
export const INITIAL_KREDITE = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Schulden Kerstin',
|
||||
typ: 'darlehen',
|
||||
betrag: 5000,
|
||||
rate: 200,
|
||||
zinsen: 0,
|
||||
start: '2024-01',
|
||||
laufzeit: 3,
|
||||
richtung: 'ausgehend',
|
||||
zahlungen: []
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Schulden Niki',
|
||||
typ: 'darlehen',
|
||||
betrag: 3000,
|
||||
rate: 100,
|
||||
zinsen: 0,
|
||||
start: '2024-06',
|
||||
laufzeit: 3,
|
||||
richtung: 'ausgehend',
|
||||
zahlungen: []
|
||||
}
|
||||
];
|
||||
|
||||
export const INITIAL_STUNDEN = [];
|
||||
|
||||
export const INITIAL_NEBENKOSTEN = [
|
||||
{ id: 1, position: 'Heizung', betrag: 1200, jahr: 2020, splitTyp: '30', vorname: 'Max', nachname: 'Mustermann', strasse: 'Musterstr.', hausnummer: '1', plz: '12345', ort: 'Musterstadt', einzugDatum: '2020-01-01', auszugDatum: '2020-12-31', tage: 366, endbetrag: 360 },
|
||||
{ id: 2, position: 'Wasser', betrag: 480, jahr: 2020, splitTyp: '30', vorname: 'Max', nachname: 'Mustermann', strasse: 'Musterstr.', hausnummer: '1', plz: '12345', ort: 'Musterstadt', einzugDatum: '2020-01-01', auszugDatum: '2020-12-31', tage: 366, endbetrag: 144 },
|
||||
{ id: 3, position: 'Müll', betrag: 300, jahr: 2020, splitTyp: '70', vorname: 'Max', nachname: 'Mustermann', strasse: 'Musterstr.', hausnummer: '1', plz: '12345', ort: 'Musterstadt', einzugDatum: '2020-01-01', auszugDatum: '2020-12-31', tage: 366, endbetrag: 210 },
|
||||
{ id: 4, position: 'Gebäudeversicherung', betrag: 600, jahr: 2020, splitTyp: '100', vorname: 'Max', nachname: 'Mustermann', strasse: 'Musterstr.', hausnummer: '1', plz: '12345', ort: 'Musterstadt', einzugDatum: '2020-01-01', auszugDatum: '2020-12-31', tage: 366, endbetrag: 600 },
|
||||
{ id: 5, position: 'Hauswart', betrag: 420, jahr: 2020, splitTyp: '70', vorname: 'Max', nachname: 'Mustermann', strasse: 'Musterstr.', hausnummer: '1', plz: '12345', ort: 'Musterstadt', einzugDatum: '2020-01-01', auszugDatum: '2020-12-31', tage: 366, endbetrag: 294 },
|
||||
{ id: 6, position: 'Instandhaltung', betrag: 120, jahr: 2020, splitTyp: '100', vorname: 'Max', nachname: 'Mustermann', strasse: 'Musterstr.', hausnummer: '1', plz: '12345', ort: 'Musterstadt', einzugDatum: '2020-01-01', auszugDatum: '2020-12-31', tage: 366, endbetrag: 120 }
|
||||
];
|
||||
|
||||
export const INITIAL_PLANUNG = [
|
||||
{ id: 1, name: 'Gewerbeversicherung', betrag: 80, kategorie: 'fix', intervall: 'monatlich', startJahr: 2026, endJahr: 2030 },
|
||||
{ id: 2, name: 'Steuerberater', betrag: 150, kategorie: 'fix', intervall: 'monatlich', startJahr: 2026, endJahr: 2030 },
|
||||
{ id: 3, name: 'Berufshaftpflicht', betrag: 250, kategorie: 'fix', intervall: 'jährlich', startJahr: 2026, endJahr: 2030 },
|
||||
{ id: 4, name: 'KFZ Versicherung', betrag: 600, kategorie: 'fix', intervall: 'jährlich', startJahr: 2026, endJahr: 2030 }
|
||||
];
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:3000',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://backend:3000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user