Initial commit
This commit is contained in:
6
frontend/.dockerignore
Normal file
6
frontend/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
build
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.DS_Store
|
||||
13
frontend/Dockerfile
Normal file
13
frontend/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
# Frontend Dockerfile for React development server
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install && chmod +x ./node_modules/.bin/*
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
0
frontend/__init__.py
Normal file
0
frontend/__init__.py
Normal file
18801
frontend/package-lock.json
generated
Normal file
18801
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
frontend/package.json
Normal file
41
frontend/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "firefly-analytics",
|
||||
"version": "1.0.0",
|
||||
"description": "Analytics and reporting dashboard for Firefly III",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"axios": "^1.6.2",
|
||||
"recharts": "^2.10.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^4.9.5",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"react-scripts": "5.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
10
frontend/public/index.html
Normal file
10
frontend/public/index.html
Normal file
@@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Firefly III Analytics</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
22
frontend/src/App.tsx
Normal file
22
frontend/src/App.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { ReportsPage } from './pages/ReportsPage';
|
||||
|
||||
const App = () => {
|
||||
const [currentPage, setCurrentPage] = useState('dashboard');
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'Arial, sans-serif' }}>
|
||||
<nav style={{ background: '#333', padding: '1rem', color: 'white' }}>
|
||||
<button onClick={() => setCurrentPage('dashboard')}>Dashboard</button>
|
||||
<button onClick={() => setCurrentPage('reports')}>Reports</button>
|
||||
</nav>
|
||||
<main>
|
||||
{currentPage === 'dashboard' && <Dashboard />}
|
||||
{currentPage === 'reports' && <ReportsPage />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
12
frontend/src/components/DateRangePicker.tsx
Normal file
12
frontend/src/components/DateRangePicker.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
export const DateRangePicker = ({ onDateRangeChange }) => {
|
||||
const [rangeType, setRangeType] = useState('30days');
|
||||
|
||||
return (
|
||||
<div style={{ padding: '1rem', border: '1px solid #ccc' }}>
|
||||
<h3>Reporting Period</h3>
|
||||
<button onClick={() => setRangeType('30days')}>Last 30 Days</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
37
frontend/src/components/SpendingChart.tsx
Normal file
37
frontend/src/components/SpendingChart.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts';
|
||||
|
||||
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#845EC2', '#FF6F91', '#00BFFF'];
|
||||
|
||||
export const SpendingChart = ({ data, title = 'Spending by Category' }) => {
|
||||
return (
|
||||
<div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: 8, background: '#fff' }}>
|
||||
<h3 style={{ marginBottom: '1rem' }}>{title}</h3>
|
||||
{data && data.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="amount"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={70}
|
||||
outerRadius={110}
|
||||
paddingAngle={4}
|
||||
label={({ name }) => name}
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value) => `$${Number(value).toFixed(2)}`} />
|
||||
<Legend verticalAlign="bottom" height={36} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p style={{ margin: 0, color: '#666' }}>No spending data available.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
19
frontend/src/components/TrendChart.tsx
Normal file
19
frontend/src/components/TrendChart.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { LineChart, Line, XAxis, YAxis, ResponsiveContainer } from 'recharts';
|
||||
|
||||
export const TrendChart = ({ data, title = 'Spending Trends' }) => {
|
||||
return (
|
||||
<div style={{ padding: '1rem' }}>
|
||||
<h3>{title}</h3>
|
||||
{data && data.length > 0 && (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={data}>
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Line type="monotone" dataKey="amount" stroke="#8884d8" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
6
frontend/src/index.tsx
Normal file
6
frontend/src/index.tsx
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 />);
|
||||
70
frontend/src/pages/Dashboard.tsx
Normal file
70
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { apiService } from '../services/api';
|
||||
|
||||
export const Dashboard = () => {
|
||||
const [summary, setSummary] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSummary = async () => {
|
||||
try {
|
||||
const data = await apiService.getDashboardSummary();
|
||||
setSummary(data);
|
||||
} catch (err: any) {
|
||||
const message = err?.response?.data?.detail || err?.message || String(err);
|
||||
setError(`Unable to load dashboard summary: ${message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSummary();
|
||||
}, []);
|
||||
|
||||
const cards = summary
|
||||
? [
|
||||
{ label: 'Total Transactions', value: summary.total_transactions ?? 0 },
|
||||
{ label: 'Total Income', value: summary.total_income ?? 0 },
|
||||
{ label: 'Total Expenses', value: summary.total_expenses ?? 0 },
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem' }}>
|
||||
<h1>Financial Dashboard</h1>
|
||||
{loading && <p>Loading dashboard data...</p>}
|
||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||
|
||||
{!loading && !error && summary && (
|
||||
<>
|
||||
<div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', marginTop: '1rem' }}>
|
||||
{cards.map((card) => (
|
||||
<div key={card.label} style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: 8, background: '#fff' }}>
|
||||
<p style={{ margin: 0, color: '#666' }}>{card.label}</p>
|
||||
<p style={{ margin: '0.5rem 0 0', fontSize: '1.5rem', fontWeight: 700 }}>${Number(card.value).toLocaleString()}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{summary.top_categories && summary.top_categories.length > 0 ? (
|
||||
<div style={{ marginTop: '1.5rem', padding: '1rem', border: '1px solid #ddd', borderRadius: 8, background: '#fff' }}>
|
||||
<h2 style={{ marginTop: 0 }}>Top Spending Categories</h2>
|
||||
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
|
||||
{summary.top_categories.map((category) => (
|
||||
<li key={category.name} style={{ marginBottom: '0.5rem' }}>
|
||||
<strong>{category.name}</strong>: ${Number(category.amount).toFixed(2)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ marginTop: '1.5rem', color: '#666' }}>No top category data available.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && !error && (!summary || Object.keys(summary).length === 0) && <p>No summary data is available.</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
47
frontend/src/pages/ReportsPage.tsx
Normal file
47
frontend/src/pages/ReportsPage.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { DateRangePicker } from '../components/DateRangePicker';
|
||||
import { SpendingChart } from '../components/SpendingChart';
|
||||
import { apiService } from '../services/api';
|
||||
|
||||
export const ReportsPage = () => {
|
||||
const [chartData, setChartData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchReportData = async () => {
|
||||
try {
|
||||
const data = await apiService.getSpendingByCategory();
|
||||
setChartData(data);
|
||||
} catch (err: any) {
|
||||
const message = err?.response?.data?.detail || err?.message || String(err);
|
||||
setError(`Unable to load report data: ${message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchReportData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem' }}>
|
||||
<h1>Analytics & Reports</h1>
|
||||
<DateRangePicker onDateRangeChange={() => {}} />
|
||||
{loading && <p>Loading report data...</p>}
|
||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||
|
||||
{!loading && !error && (
|
||||
<div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: '1fr' }}>
|
||||
<div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: 8, background: '#fff' }}>
|
||||
<h2 style={{ marginTop: 0 }}>Spending Summary</h2>
|
||||
<p style={{ margin: 0, color: '#666' }}>
|
||||
Showing spending by category using live Firefly III transaction data.
|
||||
</p>
|
||||
</div>
|
||||
<SpendingChart data={chartData} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
frontend/src/react-app-env.d.ts
vendored
Normal file
1
frontend/src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
16
frontend/src/services/api.ts
Normal file
16
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
export const api = axios.create({ baseURL: API_BASE_URL });
|
||||
|
||||
export const apiService = {
|
||||
getDashboardSummary: async () => {
|
||||
const response = await api.get('/api/summary/dashboard');
|
||||
return response.data ?? {};
|
||||
},
|
||||
getSpendingByCategory: async () => {
|
||||
const response = await api.get('/api/reports/spending-by-category');
|
||||
const payload = response.data?.data;
|
||||
return Array.isArray(payload) ? payload : [];
|
||||
},
|
||||
};
|
||||
14
frontend/src/types.ts
Normal file
14
frontend/src/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
date: string;
|
||||
amount: number;
|
||||
description: string;
|
||||
type: 'withdrawal' | 'deposit' | 'transfer';
|
||||
category_id: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'expense' | 'revenue';
|
||||
}
|
||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user