genesis
This commit is contained in:
12
client/public/index.html
Normal file
12
client/public/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FibDash</title>
|
||||
<meta name="google-signin-client_id" content="%REACT_APP_GOOGLE_CLIENT_ID%">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
141
client/src/App.js
Normal file
141
client/src/App.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import React, { Component } from 'react';
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import { Container, AppBar, Toolbar, Typography, Button, Box } from '@mui/material';
|
||||
import LoginIcon from '@mui/icons-material/Login';
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import AuthService from './services/AuthService';
|
||||
import DataViewer from './components/DataViewer';
|
||||
import Login from './components/Login';
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#1976d2',
|
||||
},
|
||||
secondary: {
|
||||
main: '#dc004e',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
class App extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
loading: true,
|
||||
};
|
||||
this.authService = new AuthService();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.checkAuthStatus();
|
||||
}
|
||||
|
||||
checkAuthStatus = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
const user = await this.authService.verifyToken(token);
|
||||
if (user) {
|
||||
this.setState({ isAuthenticated: true, user, loading: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
this.setState({ loading: false });
|
||||
};
|
||||
|
||||
handleLogin = async (tokenResponse) => {
|
||||
try {
|
||||
const result = await this.authService.googleLogin(tokenResponse);
|
||||
if (result.success) {
|
||||
localStorage.setItem('token', result.token);
|
||||
this.setState({ isAuthenticated: true, user: result.user });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
// The error handling will be done in the Login component's handleGoogleResponse
|
||||
throw error; // Re-throw to let Login component handle the display
|
||||
}
|
||||
};
|
||||
|
||||
handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
this.setState({ isAuthenticated: false, user: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isAuthenticated, user, loading } = this.state;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Container>
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="100vh">
|
||||
<Typography>Lädt...</Typography>
|
||||
</Box>
|
||||
</Container>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<AppBar position="static">
|
||||
<Toolbar>
|
||||
<DashboardIcon sx={{ mr: { xs: 1, sm: 2 } }} />
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||
FibDash
|
||||
</Typography>
|
||||
{isAuthenticated && user && (
|
||||
<>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
mr: { xs: 1, sm: 2 },
|
||||
display: { xs: 'none', sm: 'block' }
|
||||
}}
|
||||
>
|
||||
Willkommen, {user.name}
|
||||
</Typography>
|
||||
<Button
|
||||
color="inherit"
|
||||
onClick={this.handleLogout}
|
||||
size="small"
|
||||
sx={{
|
||||
minWidth: { xs: 'auto', sm: 'auto' },
|
||||
px: { xs: 1, sm: 2 }
|
||||
}}
|
||||
>
|
||||
<LoginIcon sx={{ mr: { xs: 0, sm: 1 } }} />
|
||||
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>
|
||||
Abmelden
|
||||
</Box>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Container maxWidth="xl" sx={{ mt: 4 }}>
|
||||
{isAuthenticated ? (
|
||||
<DataViewer user={user} />
|
||||
) : (
|
||||
<Login onLogin={this.handleLogin} />
|
||||
)}
|
||||
</Container>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default App;
|
||||
199
client/src/components/Dashboard.js
Normal file
199
client/src/components/Dashboard.js
Normal file
@@ -0,0 +1,199 @@
|
||||
import React, { Component } from 'react';
|
||||
import {
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Paper,
|
||||
Avatar,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
TrendingUp as TrendingUpIcon,
|
||||
People as PeopleIcon,
|
||||
Assessment as AssessmentIcon,
|
||||
Timeline as TimelineIcon,
|
||||
} from '@mui/icons-material';
|
||||
import AuthService from '../services/AuthService';
|
||||
|
||||
class Dashboard extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
dashboardData: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
};
|
||||
this.authService = new AuthService();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.loadDashboardData();
|
||||
}
|
||||
|
||||
loadDashboardData = async () => {
|
||||
try {
|
||||
const response = await this.authService.apiCall('/dashboard');
|
||||
if (response && response.ok) {
|
||||
const data = await response.json();
|
||||
// Map icon names to actual components
|
||||
const statsWithIcons = data.stats.map(stat => ({
|
||||
...stat,
|
||||
icon: this.getIconComponent(stat.icon)
|
||||
}));
|
||||
this.setState({
|
||||
dashboardData: { ...data, stats: statsWithIcons },
|
||||
loading: false
|
||||
});
|
||||
} else {
|
||||
// Fallback data when API is not available
|
||||
this.setState({
|
||||
dashboardData: {
|
||||
stats: [
|
||||
{ title: 'Total Users', value: 'Loading...', icon: PeopleIcon, color: '#1976d2' },
|
||||
{ title: 'Revenue', value: 'Loading...', icon: TrendingUpIcon, color: '#388e3c' },
|
||||
{ title: 'Reports', value: 'Loading...', icon: AssessmentIcon, color: '#f57c00' },
|
||||
{ title: 'Growth', value: 'Loading...', icon: TimelineIcon, color: '#7b1fa2' },
|
||||
],
|
||||
recentActivity: []
|
||||
},
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard data:', error);
|
||||
this.setState({ error: 'Failed to load dashboard data', loading: false });
|
||||
}
|
||||
};
|
||||
|
||||
getIconComponent = (iconName) => {
|
||||
const iconMap = {
|
||||
'PeopleIcon': PeopleIcon,
|
||||
'TrendingUpIcon': TrendingUpIcon,
|
||||
'AssessmentIcon': AssessmentIcon,
|
||||
'TimelineIcon': TimelineIcon,
|
||||
};
|
||||
return iconMap[iconName] || PeopleIcon;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { user } = this.props;
|
||||
const { dashboardData, loading, error } = this.state;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
|
||||
<Typography>Dashboard lädt...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
|
||||
<Typography color="error">{error}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = dashboardData?.stats || [];
|
||||
const recentActivity = dashboardData?.recentActivity || [];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* User Welcome Section */}
|
||||
<Paper elevation={1} sx={{ p: 3, mb: 4, background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
|
||||
<Box display="flex" alignItems="center">
|
||||
<Avatar
|
||||
src={user?.picture}
|
||||
alt={user?.name}
|
||||
sx={{ width: 64, height: 64, mr: 3 }}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ color: 'white', fontWeight: 'bold' }}>
|
||||
Willkommen zurück, {user?.name}!
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: 'rgba(255, 255, 255, 0.8)' }}>
|
||||
{user?.email}
|
||||
</Typography>
|
||||
<Chip
|
||||
label="Aktiv"
|
||||
size="small"
|
||||
sx={{ mt: 1, backgroundColor: 'rgba(255, 255, 255, 0.2)', color: 'white' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<Grid container spacing={3} sx={{ mb: 4 }}>
|
||||
{stats.map((stat, index) => {
|
||||
const IconComponent = typeof stat.icon === 'string' ? this.getIconComponent(stat.icon) : stat.icon;
|
||||
return (
|
||||
<Grid item xs={12} sm={6} md={3} key={index}>
|
||||
<Card elevation={2} sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography variant="h6" component="div" gutterBottom>
|
||||
{stat.title}
|
||||
</Typography>
|
||||
<Typography variant="h4" component="div" sx={{ fontWeight: 'bold' }}>
|
||||
{stat.value}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: stat.color,
|
||||
borderRadius: 2,
|
||||
p: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<IconComponent sx={{ color: 'white', fontSize: 32 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Paper elevation={2} sx={{ p: 3 }}>
|
||||
<Typography variant="h5" component="h2" gutterBottom>
|
||||
Letzte Aktivitäten
|
||||
</Typography>
|
||||
{recentActivity.length > 0 ? (
|
||||
<Box>
|
||||
{recentActivity.map((activity, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderBottom: index < recentActivity.length - 1 ? '1px solid #e0e0e0' : 'none',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1">{activity.description}</Typography>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
{activity.timestamp}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body1" color="textSecondary">
|
||||
Keine aktuellen Aktivitäten vorhanden. Beginnen Sie mit der Nutzung des Systems, um hier Updates zu sehen.
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
141
client/src/components/DataViewer.js
Normal file
141
client/src/components/DataViewer.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import React, { Component } from 'react';
|
||||
import {
|
||||
Box,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import AuthService from '../services/AuthService';
|
||||
import SummaryHeader from './SummaryHeader';
|
||||
import TransactionsTable from './TransactionsTable';
|
||||
|
||||
class DataViewer extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
months: [],
|
||||
selectedMonth: '',
|
||||
transactions: [],
|
||||
summary: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
};
|
||||
this.authService = new AuthService();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.loadMonths();
|
||||
}
|
||||
|
||||
loadMonths = async () => {
|
||||
try {
|
||||
const response = await this.authService.apiCall('/data/months');
|
||||
if (response && response.ok) {
|
||||
const data = await response.json();
|
||||
this.setState({
|
||||
months: data.months,
|
||||
selectedMonth: data.months[0] || '', // Select newest month
|
||||
loading: false
|
||||
});
|
||||
|
||||
// Load data for the newest month
|
||||
if (data.months[0]) {
|
||||
this.loadTransactions(data.months[0]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading months:', error);
|
||||
this.setState({ error: 'Fehler beim Laden der Monate', loading: false });
|
||||
}
|
||||
};
|
||||
|
||||
loadTransactions = async (monthYear) => {
|
||||
this.setState({ loading: true });
|
||||
try {
|
||||
const response = await this.authService.apiCall(`/data/transactions/${monthYear}`);
|
||||
if (response && response.ok) {
|
||||
const data = await response.json();
|
||||
this.setState({
|
||||
transactions: data.transactions,
|
||||
summary: data.summary,
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading transactions:', error);
|
||||
this.setState({ error: 'Fehler beim Laden der Transaktionen', loading: false });
|
||||
}
|
||||
};
|
||||
|
||||
handleMonthChange = (event) => {
|
||||
const monthYear = event.target.value;
|
||||
this.setState({ selectedMonth: monthYear });
|
||||
this.loadTransactions(monthYear);
|
||||
};
|
||||
|
||||
|
||||
|
||||
downloadDatev = async () => {
|
||||
const { selectedMonth } = this.state;
|
||||
if (!selectedMonth) return;
|
||||
|
||||
try {
|
||||
const response = await this.authService.apiCall(`/data/datev/${selectedMonth}`);
|
||||
if (response && response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = url;
|
||||
a.download = `DATEV_${selectedMonth.replace('-', '_')}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error downloading DATEV:', error);
|
||||
this.setState({ error: 'Fehler beim Herunterladen der DATEV-Datei' });
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { months, selectedMonth, transactions, summary, loading, error } = this.state;
|
||||
|
||||
if (loading && !transactions.length) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<SummaryHeader
|
||||
months={months}
|
||||
selectedMonth={selectedMonth}
|
||||
summary={summary}
|
||||
loading={loading}
|
||||
onMonthChange={this.handleMonthChange}
|
||||
onDownloadDatev={this.downloadDatev}
|
||||
/>
|
||||
|
||||
<TransactionsTable
|
||||
transactions={transactions}
|
||||
selectedMonth={selectedMonth}
|
||||
loading={loading}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DataViewer;
|
||||
166
client/src/components/Login.js
Normal file
166
client/src/components/Login.js
Normal file
@@ -0,0 +1,166 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Box, Paper, Typography, Button, Alert } from '@mui/material';
|
||||
import GoogleIcon from '@mui/icons-material/Google';
|
||||
|
||||
class Login extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
error: null,
|
||||
loading: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.loadGoogleScript();
|
||||
}
|
||||
|
||||
loadGoogleScript = () => {
|
||||
if (window.google && window.google.accounts) {
|
||||
this.initializeGoogleSignIn();
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://accounts.google.com/gsi/client';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => {
|
||||
this.initializeGoogleSignIn();
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
};
|
||||
|
||||
initializeGoogleSignIn = () => {
|
||||
if (window.google && window.google.accounts) {
|
||||
try {
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: process.env.REACT_APP_GOOGLE_CLIENT_ID || 'your_google_client_id_here',
|
||||
callback: this.handleGoogleResponse,
|
||||
auto_select: false,
|
||||
cancel_on_tap_outside: true,
|
||||
});
|
||||
console.log('✅ Google Sign-In initialized');
|
||||
} catch (error) {
|
||||
console.error('Google Sign-In initialization error:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleGoogleResponse = (response) => {
|
||||
this.setState({ loading: true, error: null });
|
||||
this.props.onLogin(response)
|
||||
.catch((error) => {
|
||||
console.error('Login error details:', error);
|
||||
console.error('Error message:', error.message);
|
||||
console.error('Error response:', error.response);
|
||||
|
||||
let errorMessage = 'Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.';
|
||||
|
||||
// Check if it's an authorization error
|
||||
if (error.message) {
|
||||
if (error.message.includes('Access denied') ||
|
||||
error.message.includes('not authorized') ||
|
||||
error.message.includes('403')) {
|
||||
errorMessage = '🚫 Zugriff verweigert: Ihre E-Mail-Adresse ist nicht autorisiert. Versuchen Sie, sich mit einem anderen Google-Konto anzumelden.';
|
||||
} else if (error.message.includes('No authorized users configured')) {
|
||||
errorMessage = '🔒 Kein Zugriff: Derzeit sind keine Benutzer autorisiert. Wenden Sie sich an den Administrator.';
|
||||
} else {
|
||||
// Show the actual error message from the server
|
||||
errorMessage = `❌ Anmeldefehler: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({ error: errorMessage });
|
||||
})
|
||||
.finally(() => {
|
||||
this.setState({ loading: false });
|
||||
});
|
||||
};
|
||||
|
||||
handleGoogleLogin = () => {
|
||||
// If there was a previous error, we need to reset completely
|
||||
if (this.state.error) {
|
||||
console.log('🔄 Previous error detected, reloading page...');
|
||||
this.setState({ loading: true });
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any previous error
|
||||
this.setState({ error: null, loading: false });
|
||||
|
||||
if (window.google && window.google.accounts && window.google.accounts.id) {
|
||||
try {
|
||||
window.google.accounts.id.prompt();
|
||||
} catch (error) {
|
||||
console.error('Google prompt error:', error);
|
||||
this.setState({
|
||||
error: 'Google-Anmeldung konnte nicht geladen werden. Die Seite wird aktualisiert, um es erneut zu versuchen.',
|
||||
loading: true
|
||||
});
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
error: 'Google-Anmeldung nicht geladen. Die Seite wird aktualisiert, um es erneut zu versuchen.',
|
||||
loading: true
|
||||
});
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
render() {
|
||||
const { error, loading } = this.state;
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
minHeight="60vh"
|
||||
>
|
||||
<Paper elevation={3} sx={{ p: 4, maxWidth: 400, width: '100%' }}>
|
||||
<Box textAlign="center" mb={3}>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Willkommen bei FibDash
|
||||
</Typography>
|
||||
<Typography variant="body1" color="textSecondary">
|
||||
Bitte melden Sie sich mit Ihrem Google-Konto an, um fortzufahren
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<GoogleIcon />}
|
||||
onClick={this.handleGoogleLogin}
|
||||
disabled={loading}
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
{loading ? 'Anmeldung läuft...' : 'Mit Google anmelden'}
|
||||
</Button>
|
||||
|
||||
|
||||
|
||||
<Typography variant="caption" display="block" textAlign="center" sx={{ mt: 2 }}>
|
||||
Durch die Anmeldung stimmen Sie unseren Nutzungsbedingungen und Datenschutzrichtlinien zu.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Login;
|
||||
128
client/src/components/SummaryHeader.js
Normal file
128
client/src/components/SummaryHeader.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import React, { Component } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Grid,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Download as DownloadIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
class SummaryHeader extends Component {
|
||||
formatAmount = (amount) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
getMonthName = (monthYear) => {
|
||||
if (!monthYear) return '';
|
||||
const [year, month] = monthYear.split('-');
|
||||
const date = new Date(year, month - 1);
|
||||
return date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
months,
|
||||
selectedMonth,
|
||||
summary,
|
||||
loading,
|
||||
onMonthChange,
|
||||
onDownloadDatev
|
||||
} = this.props;
|
||||
|
||||
if (!summary) return null;
|
||||
|
||||
return (
|
||||
<Paper elevation={1} sx={{ p: { xs: 1.5, sm: 2 }, mb: 2 }}>
|
||||
<Grid container alignItems="center" spacing={{ xs: 1, sm: 2 }}>
|
||||
<Grid item xs={12} md={3}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Monat</InputLabel>
|
||||
<Select
|
||||
value={selectedMonth}
|
||||
onChange={onMonthChange}
|
||||
label="Month"
|
||||
>
|
||||
{months.map((month) => (
|
||||
<MenuItem key={month} value={month}>
|
||||
{this.getMonthName(month)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={4} md={2}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="caption" color="textSecondary">Transaktionen</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#1976d2', fontSize: { xs: '0.9rem', sm: '1.25rem' } }}>
|
||||
{summary.totalTransactions}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={4} md={2}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="caption" color="textSecondary">Einnahmen</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#388e3c', fontSize: { xs: '0.9rem', sm: '1.25rem' } }}>
|
||||
{this.formatAmount(summary.totalIncome)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={4} md={2}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="caption" color="textSecondary">Ausgaben</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#d32f2f', fontSize: { xs: '0.9rem', sm: '1.25rem' } }}>
|
||||
{this.formatAmount(summary.totalExpenses)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={4} md={2}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="caption" color="textSecondary">Nettobetrag</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: summary.netAmount >= 0 ? '#388e3c' : '#d32f2f',
|
||||
fontSize: { xs: '0.9rem', sm: '1.25rem' }
|
||||
}}
|
||||
>
|
||||
{this.formatAmount(summary.netAmount)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={4} md={1}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="caption" color="textSecondary">JTL ✓</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#388e3c', fontSize: { xs: '0.9rem', sm: '1.25rem' } }}>
|
||||
{summary.jtlMatches || 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4} md={2}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={onDownloadDatev}
|
||||
disabled={!selectedMonth || loading}
|
||||
size="small"
|
||||
sx={{ height: 'fit-content' }}
|
||||
>
|
||||
DATEV Export
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default SummaryHeader;
|
||||
148
client/src/components/TransactionsTable.js
Normal file
148
client/src/components/TransactionsTable.js
Normal file
@@ -0,0 +1,148 @@
|
||||
import React, { Component } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
|
||||
class TransactionsTable extends Component {
|
||||
formatAmount = (amount) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
formatDate = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
const parts = dateString.split('.');
|
||||
if (parts.length === 3) {
|
||||
return `${parts[0]}.${parts[1]}.20${parts[2]}`;
|
||||
}
|
||||
return dateString;
|
||||
};
|
||||
|
||||
getMonthName = (monthYear) => {
|
||||
if (!monthYear) return '';
|
||||
const [year, month] = monthYear.split('-');
|
||||
const date = new Date(year, month - 1);
|
||||
return date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { transactions, selectedMonth, loading } = this.props;
|
||||
|
||||
return (
|
||||
<Paper elevation={2}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography variant="h6" component="h2" gutterBottom>
|
||||
Transaktionen für {this.getMonthName(selectedMonth)}
|
||||
</Typography>
|
||||
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" p={2}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<TableContainer sx={{ maxHeight: 700 }}>
|
||||
<Table stickyHeader size="small" sx={{ '& .MuiTableCell-root': { padding: '4px 8px', fontSize: '0.75rem', borderBottom: '1px solid #e0e0e0' } }}>
|
||||
<TableHead>
|
||||
<TableRow sx={{ '& .MuiTableCell-root': { backgroundColor: '#f5f5f5', fontWeight: 600 } }}>
|
||||
<TableCell sx={{ width: 80 }}>Datum</TableCell>
|
||||
<TableCell sx={{ width: 320 }}>Beschreibung</TableCell>
|
||||
<TableCell sx={{ width: 180 }}>Empfänger/Zahler</TableCell>
|
||||
<TableCell align="right" sx={{ width: 100 }}>Betrag</TableCell>
|
||||
<TableCell sx={{ width: 50 }}>Typ</TableCell>
|
||||
<TableCell sx={{ width: 50 }}>JTL</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{transactions.map((transaction, index) => (
|
||||
<TableRow
|
||||
key={index}
|
||||
hover
|
||||
sx={{
|
||||
'&:hover': { backgroundColor: transaction.isJTLOnly ? '#ffebee' : '#f9f9f9' },
|
||||
'& .MuiTableCell-root': {
|
||||
padding: '2px 8px',
|
||||
backgroundColor: transaction.isJTLOnly ? '#ffebee' : 'inherit',
|
||||
borderLeft: transaction.isJTLOnly ? '4px solid #f44336' : 'none'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TableCell sx={{ fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
|
||||
{this.formatDate(transaction['Buchungstag'])}
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontSize: '0.7rem', maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{transaction['Verwendungszweck'] || transaction['Buchungstext']}
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontSize: '0.7rem', maxWidth: 180, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{transaction['Beguenstigter/Zahlungspflichtiger']}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
align="right"
|
||||
sx={{
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
color: transaction.numericAmount >= 0 ? '#388e3c' : '#d32f2f',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{this.formatAmount(transaction.numericAmount)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: transaction.numericAmount >= 0 ? '#388e3c' : '#d32f2f',
|
||||
margin: 'auto'
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box
|
||||
sx={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: transaction.hasJTL ? '#388e3c' : '#f5f5f5',
|
||||
border: transaction.hasJTL ? 'none' : '1px solid #ccc',
|
||||
margin: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{transaction.hasJTL && (
|
||||
<Box sx={{
|
||||
fontSize: '8px',
|
||||
color: 'white',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
✓
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default TransactionsTable;
|
||||
6
client/src/index.js
Normal file
6
client/src/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
84
client/src/services/AuthService.js
Normal file
84
client/src/services/AuthService.js
Normal file
@@ -0,0 +1,84 @@
|
||||
class AuthService {
|
||||
constructor() {
|
||||
this.baseURL = '/api';
|
||||
}
|
||||
|
||||
async googleLogin(tokenResponse) {
|
||||
try {
|
||||
const response = await fetch(`${this.baseURL}/auth/google`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: tokenResponse.credential || tokenResponse.access_token,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
console.log('Server error response:', errorData);
|
||||
const errorMessage = errorData.message || errorData.error || `HTTP ${response.status}: Login failed`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Google login error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async verifyToken(token) {
|
||||
try {
|
||||
const response = await fetch(`${this.baseURL}/auth/verify`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.user;
|
||||
} catch (error) {
|
||||
console.error('Token verification error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async apiCall(endpoint, options = {}) {
|
||||
const token = localStorage.getItem('token');
|
||||
const defaultOptions = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||
},
|
||||
};
|
||||
|
||||
const mergedOptions = {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
headers: {
|
||||
...defaultOptions.headers,
|
||||
...options.headers,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.baseURL}${endpoint}`, mergedOptions);
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// Token is invalid or user is no longer authorized
|
||||
localStorage.removeItem('token');
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export default AuthService;
|
||||
Reference in New Issue
Block a user