Initial commit - Stand 26.04.2026

This commit is contained in:
OpenClaw
2026-04-26 07:51:39 +02:00
commit b29c467187
186 changed files with 39281 additions and 0 deletions
+18
View File
@@ -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;"]
+8
View File
@@ -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"]
+13
View File
@@ -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);
}
+13
View File
@@ -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>
+25
View File
@@ -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;
}
}
+25
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+346
View File
@@ -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>
);
}
+221
View File
@@ -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();
},
};
+314
View File
@@ -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>
);
}
+447
View File
@@ -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>
);
}
+187
View File
@@ -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>
);
}
+457
View File
@@ -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
+500
View File
@@ -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>
);
}
+424
View File
@@ -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>
);
}
+655
View File
@@ -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} 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>
);
}
+530
View File
@@ -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>
);
}
+444
View File
@@ -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>
);
}
+451
View File
@@ -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>
);
}
+396
View File
@@ -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>
);
}
+109
View File
@@ -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 () *</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 ()</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 () *</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"></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)} </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>
);
}
+162
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+46
View File
@@ -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 }
];
+10
View File
@@ -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>,
)
+11
View File
@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
+20
View File
@@ -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
}
}
}
})