Add purchase date picker and sells feature
- Purchase form now includes a date picker (defaults to today) - New Sell model, CRUD endpoints (/sells), and stats integration - AddSell and SellList components added to dashboard - Portfolio chart updated to reflect sells over time Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ const styles = {
|
||||
};
|
||||
|
||||
export default function AddPurchase({ onAdded }) {
|
||||
const [purchaseDate, setPurchaseDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [amountEur, setAmountEur] = useState('');
|
||||
const [priceEur, setPriceEur] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
@@ -30,12 +31,14 @@ export default function AddPurchase({ onAdded }) {
|
||||
body: JSON.stringify({
|
||||
amount_eur: parseFloat(amountEur),
|
||||
price_eur: parseFloat(priceEur),
|
||||
created_at: new Date(purchaseDate + 'T12:00:00').toISOString(),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError('Failed to add purchase');
|
||||
return;
|
||||
}
|
||||
setPurchaseDate(new Date().toISOString().split('T')[0]);
|
||||
setAmountEur('');
|
||||
setPriceEur('');
|
||||
onAdded();
|
||||
@@ -50,6 +53,13 @@ export default function AddPurchase({ onAdded }) {
|
||||
<div style={styles.title}>Add Purchase</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={styles.row}>
|
||||
<input
|
||||
style={styles.input}
|
||||
type="date"
|
||||
value={purchaseDate}
|
||||
onChange={e => setPurchaseDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
style={styles.input}
|
||||
type="number"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
|
||||
const styles = {
|
||||
card: { background: '#1a1a1a', padding: '1.5rem', borderRadius: '12px', border: '1px solid #333', marginBottom: '1.5rem' },
|
||||
title: { fontSize: '1.1rem', fontWeight: 700, marginBottom: '1rem', color: '#f7931a' },
|
||||
row: { display: 'flex', gap: '0.75rem', flexWrap: 'wrap' },
|
||||
input: { flex: 1, minWidth: '140px', padding: '0.65rem', background: '#2a2a2a', border: '1px solid #444', borderRadius: '8px', color: '#e0e0e0', fontSize: '1rem' },
|
||||
button: { padding: '0.65rem 1.5rem', background: '#f7931a', color: '#000', border: 'none', borderRadius: '8px', fontWeight: 700, cursor: 'pointer' },
|
||||
error: { color: '#ff6b6b', marginTop: '0.5rem', fontSize: '0.9rem' },
|
||||
};
|
||||
|
||||
export default function AddSell({ onAdded }) {
|
||||
const [sellDate, setSellDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [btcAmount, setBtcAmount] = useState('');
|
||||
const [priceEur, setPriceEur] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
const token = localStorage.getItem('token');
|
||||
try {
|
||||
const res = await fetch(`${API}/sells`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
btc_amount: parseFloat(btcAmount),
|
||||
price_eur: parseFloat(priceEur),
|
||||
created_at: new Date(sellDate + 'T12:00:00').toISOString(),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError('Failed to add sell');
|
||||
return;
|
||||
}
|
||||
setSellDate(new Date().toISOString().split('T')[0]);
|
||||
setBtcAmount('');
|
||||
setPriceEur('');
|
||||
onAdded();
|
||||
} catch (err) {
|
||||
console.error('AddSell network error:', err);
|
||||
setError('Network error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.card}>
|
||||
<div style={styles.title}>Add Sell</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={styles.row}>
|
||||
<input
|
||||
style={styles.input}
|
||||
type="date"
|
||||
value={sellDate}
|
||||
onChange={e => setSellDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
style={styles.input}
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="BTC Amount"
|
||||
value={btcAmount}
|
||||
onChange={e => setBtcAmount(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
style={styles.input}
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="BTC Price (EUR)"
|
||||
value={priceEur}
|
||||
onChange={e => setPriceEur(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button style={styles.button} type="submit">Add</button>
|
||||
</div>
|
||||
{error && <div style={styles.error}>{error}</div>}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -42,12 +42,13 @@ function priceOn(date, priceMap, currentPrice, isToday, sortedPurchases) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export default function PortfolioChart({ purchases, stats, btcHistory }) {
|
||||
export default function PortfolioChart({ purchases, sells, stats, btcHistory }) {
|
||||
const chartRef = useRef(null);
|
||||
|
||||
if (!purchases || purchases.length === 0) return null;
|
||||
|
||||
const sorted = [...purchases].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||
const sortedSells = [...(sells || [])].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
@@ -89,6 +90,14 @@ export default function PortfolioChart({ purchases, stats, btcHistory }) {
|
||||
cumInvested += p.amount_eur;
|
||||
}
|
||||
});
|
||||
sortedSells.forEach(s => {
|
||||
const sDate = new Date(s.created_at);
|
||||
sDate.setHours(0, 0, 0, 0);
|
||||
if (sDate <= date) {
|
||||
cumBtc -= s.btc_amount;
|
||||
cumInvested -= s.btc_amount * s.price_eur;
|
||||
}
|
||||
});
|
||||
|
||||
if (cumBtc === 0) return; // no purchases yet at this date
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
|
||||
const styles = {
|
||||
card: { background: '#1a1a1a', padding: '1.5rem', borderRadius: '12px', border: '1px solid #333', marginBottom: '1.5rem' },
|
||||
title: { fontSize: '1.1rem', fontWeight: 700, marginBottom: '1rem', color: '#f7931a' },
|
||||
table: { width: '100%', borderCollapse: 'collapse' },
|
||||
th: { textAlign: 'left', padding: '0.5rem 0.75rem', borderBottom: '1px solid #333', color: '#888', fontSize: '0.85rem' },
|
||||
td: { padding: '0.6rem 0.75rem', borderBottom: '1px solid #222', fontSize: '0.95rem' },
|
||||
input: { background: '#2a2a2a', border: '1px solid #555', borderRadius: '6px', color: '#e0e0e0', padding: '0.3rem 0.5rem', fontSize: '0.9rem', width: '100%' },
|
||||
editBtn: { background: 'none', border: '1px solid #555', color: '#4fc3f7', borderRadius: '6px', padding: '0.25rem 0.6rem', cursor: 'pointer', fontSize: '0.85rem', marginRight: '0.4rem' },
|
||||
saveBtn: { background: 'none', border: '1px solid #6bff8e', color: '#6bff8e', borderRadius: '6px', padding: '0.25rem 0.6rem', cursor: 'pointer', fontSize: '0.85rem', marginRight: '0.4rem' },
|
||||
cancelBtn: { background: 'none', border: '1px solid #555', color: '#aaa', borderRadius: '6px', padding: '0.25rem 0.6rem', cursor: 'pointer', fontSize: '0.85rem', marginRight: '0.4rem' },
|
||||
deleteBtn: { background: 'none', border: '1px solid #555', color: '#ff6b6b', borderRadius: '6px', padding: '0.25rem 0.6rem', cursor: 'pointer', fontSize: '0.85rem' },
|
||||
empty: { color: '#555', textAlign: 'center', padding: '1rem' },
|
||||
};
|
||||
|
||||
function toDateInputValue(isoString) {
|
||||
return isoString ? isoString.slice(0, 10) : '';
|
||||
}
|
||||
|
||||
export default function SellList({ sells, onChanged }) {
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [editForm, setEditForm] = useState({});
|
||||
|
||||
const token = () => localStorage.getItem('token');
|
||||
|
||||
const startEdit = (s) => {
|
||||
setEditingId(s.id);
|
||||
setEditForm({
|
||||
btc_amount: s.btc_amount,
|
||||
price_eur: s.price_eur,
|
||||
created_at: toDateInputValue(s.created_at),
|
||||
});
|
||||
};
|
||||
|
||||
const cancelEdit = () => setEditingId(null);
|
||||
|
||||
const handleSave = async (id) => {
|
||||
const res = await fetch(`${API}/sells/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token()}` },
|
||||
body: JSON.stringify({
|
||||
btc_amount: parseFloat(editForm.btc_amount),
|
||||
price_eur: parseFloat(editForm.price_eur),
|
||||
created_at: new Date(editForm.created_at + 'T12:00:00').toISOString(),
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setEditingId(null);
|
||||
onChanged();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
await fetch(`${API}/sells/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token()}` },
|
||||
});
|
||||
onChanged();
|
||||
};
|
||||
|
||||
const set = (field) => (e) => setEditForm(f => ({ ...f, [field]: e.target.value }));
|
||||
|
||||
return (
|
||||
<div style={styles.card}>
|
||||
<div style={styles.title}>Sells</div>
|
||||
{sells.length === 0 ? (
|
||||
<div style={styles.empty}>No sells yet.</div>
|
||||
) : (
|
||||
<table style={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={styles.th}>Date</th>
|
||||
<th style={styles.th}>BTC Amount</th>
|
||||
<th style={styles.th}>Price (€/BTC)</th>
|
||||
<th style={styles.th}>Value (€)</th>
|
||||
<th style={styles.th}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sells.map(s => editingId === s.id ? (
|
||||
<tr key={s.id}>
|
||||
<td style={styles.td}>
|
||||
<input style={styles.input} type="date" value={editForm.created_at} onChange={set('created_at')} />
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
<input style={styles.input} type="number" step="any" value={editForm.btc_amount} onChange={set('btc_amount')} />
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
<input style={styles.input} type="number" step="any" value={editForm.price_eur} onChange={set('price_eur')} />
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
€{(parseFloat(editForm.btc_amount) * parseFloat(editForm.price_eur) || 0).toLocaleString()}
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
<button style={styles.saveBtn} onClick={() => handleSave(s.id)}>Save</button>
|
||||
<button style={styles.cancelBtn} onClick={cancelEdit}>Cancel</button>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr key={s.id}>
|
||||
<td style={styles.td}>{new Date(s.created_at).toLocaleDateString('en-GB')}</td>
|
||||
<td style={styles.td}>₿{s.btc_amount.toFixed(8)}</td>
|
||||
<td style={styles.td}>€{s.price_eur.toLocaleString()}</td>
|
||||
<td style={styles.td}>€{(s.btc_amount * s.price_eur).toLocaleString()}</td>
|
||||
<td style={styles.td}>
|
||||
<button style={styles.editBtn} onClick={() => startEdit(s)}>Edit</button>
|
||||
<button style={styles.deleteBtn} onClick={() => handleDelete(s.id)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import AddPurchase from '../components/AddPurchase';
|
||||
import PurchaseList from '../components/PurchaseList';
|
||||
import AddSell from '../components/AddSell';
|
||||
import SellList from '../components/SellList';
|
||||
import PortfolioChart from '../components/PortfolioChart';
|
||||
import BTCCandlestickChart from '../components/BTCCandlestickChart';
|
||||
|
||||
@@ -38,6 +40,7 @@ function StatCard({ label, value, highlight }) {
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [purchases, setPurchases] = useState([]);
|
||||
const [sells, setSells] = useState([]);
|
||||
const [candles, setCandles] = useState(null);
|
||||
const [candlesAll, setCandlesAll] = useState(null);
|
||||
const [fullscreenChart, setFullscreenChart] = useState(false);
|
||||
@@ -50,9 +53,10 @@ export default function Dashboard() {
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [statsRes, purchasesRes, candlesRes] = await Promise.all([
|
||||
const [statsRes, purchasesRes, sellsRes, candlesRes] = await Promise.all([
|
||||
fetch(`${API}/stats`, { headers: authHeaders() }),
|
||||
fetch(`${API}/purchases`, { headers: authHeaders() }),
|
||||
fetch(`${API}/sells`, { headers: authHeaders() }),
|
||||
fetch(`${API}/candles?days=365`, { headers: authHeaders() }),
|
||||
]);
|
||||
if (statsRes.status === 401) {
|
||||
@@ -62,6 +66,7 @@ export default function Dashboard() {
|
||||
}
|
||||
setStats(await statsRes.json());
|
||||
setPurchases(await purchasesRes.json());
|
||||
setSells(await sellsRes.json());
|
||||
setCandles(await candlesRes.json());
|
||||
} catch {
|
||||
// silently fail — network may be unavailable
|
||||
@@ -135,7 +140,7 @@ export default function Dashboard() {
|
||||
>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
{(chartView === 'both' || chartView === 'portfolio') && <PortfolioChart purchases={purchases} stats={stats} btcHistory={candles?.candles ?? []} />}
|
||||
{(chartView === 'both' || chartView === 'portfolio') && <PortfolioChart purchases={purchases} sells={sells} stats={stats} btcHistory={candles?.candles ?? []} />}
|
||||
{(chartView === 'both' || chartView === 'history') && (
|
||||
<BTCCandlestickChart
|
||||
candles={activeCandles?.candles ?? null}
|
||||
@@ -147,6 +152,8 @@ export default function Dashboard() {
|
||||
)}
|
||||
<AddPurchase onAdded={fetchData} />
|
||||
<PurchaseList purchases={purchases} onChanged={fetchData} />
|
||||
<AddSell onAdded={fetchData} />
|
||||
<SellList sells={sells} onChanged={fetchData} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user