Investment portfolio charts, search fix, and holding creation fixes
- Add four portfolio charts: allocation donut by holding, allocation donut by asset type, cost basis vs current value bar, return % bar - Fix asset search to use yf.Search() full-text instead of ticker-only lookup — name searches like "vanguard ftse all world" now work - Fix holding creation double-quantity bug: holdings now created with quantity=0 so buy transaction is sole source of quantity/cost basis - Add per-share / total price toggle in Add Holding modal with live calculated equivalent shown as you type - Add ErrorBoundary in AppShell so render errors show a message instead of a blank page - Fix donut charts using || instead of ?? when falling back from current_value to cost_basis_total (0 was not falling through ??) - Allow HoldingCreate.quantity >= 0 (was gt=0) to support zero-init - Fix error display for Pydantic v2 array-of-objects validation errors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
26e2a055db
commit
cdc1e67321
9 changed files with 424 additions and 70 deletions
|
|
@ -27,6 +27,7 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
|
|||
["investment", "pension", "savings", "other"].includes(a.type)
|
||||
);
|
||||
|
||||
const [priceMode, setPriceMode] = useState<"per_share" | "total">("per_share");
|
||||
const [form, setForm] = useState({
|
||||
account_id: investAccounts[0]?.id ?? "",
|
||||
quantity: "",
|
||||
|
|
@ -57,12 +58,13 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
|
|||
setError(null);
|
||||
try {
|
||||
const qty = parseFloat(form.quantity);
|
||||
const price = parseFloat(form.price);
|
||||
const rawPrice = parseFloat(form.price);
|
||||
const price = priceMode === "total" ? rawPrice / qty : rawPrice;
|
||||
const holding = await createHolding({
|
||||
account_id: form.account_id,
|
||||
asset_id: selected.id,
|
||||
quantity: qty,
|
||||
avg_cost_basis: price,
|
||||
quantity: 0,
|
||||
avg_cost_basis: 0,
|
||||
currency: form.currency,
|
||||
});
|
||||
await addInvestmentTransaction({
|
||||
|
|
@ -76,7 +78,8 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
|
|||
});
|
||||
onSuccess();
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.detail ?? "Failed to add holding");
|
||||
const detail = e?.response?.data?.detail;
|
||||
setError(Array.isArray(detail) ? detail.map((d: any) => d.msg).join(", ") : (detail ?? "Failed to add holding"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -159,8 +162,28 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
|
|||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium block mb-1">Price paid *</label>
|
||||
<div className="col-span-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs font-medium">
|
||||
{priceMode === "per_share" ? "Price per share *" : "Total price paid *"}
|
||||
</label>
|
||||
<div className="flex rounded-md border border-input overflow-hidden text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPriceMode("per_share")}
|
||||
className={`px-2 py-0.5 transition-colors ${priceMode === "per_share" ? "bg-primary text-primary-foreground" : "hover:bg-secondary"}`}
|
||||
>
|
||||
Per share
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPriceMode("total")}
|
||||
className={`px-2 py-0.5 transition-colors ${priceMode === "total" ? "bg-primary text-primary-foreground" : "hover:bg-secondary"}`}
|
||||
>
|
||||
Total
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<select
|
||||
value={form.currency}
|
||||
|
|
@ -174,20 +197,28 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
|
|||
value={form.price}
|
||||
onChange={(e) => setForm(f => ({ ...f, price: e.target.value }))}
|
||||
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="150.00"
|
||||
placeholder={priceMode === "per_share" ? "150.00" : "1500.00"}
|
||||
/>
|
||||
</div>
|
||||
{form.price && form.quantity && parseFloat(form.quantity) > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{priceMode === "per_share"
|
||||
? `Total: ${form.currency} ${(parseFloat(form.price) * parseFloat(form.quantity)).toFixed(2)}`
|
||||
: `Per share: ${form.currency} ${(parseFloat(form.price) / parseFloat(form.quantity)).toFixed(4)}`
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium block mb-1">Fees</label>
|
||||
<input
|
||||
type="number" min="0" step="any"
|
||||
value={form.fees}
|
||||
onChange={(e) => setForm(f => ({ ...f, fees: e.target.value }))}
|
||||
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium block mb-1">Fees</label>
|
||||
<input
|
||||
type="number" min="0" step="any"
|
||||
value={form.fees}
|
||||
onChange={(e) => setForm(f => ({ ...f, fees: e.target.value }))}
|
||||
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selected && form.currency !== selected.currency && (
|
||||
|
|
@ -213,6 +244,7 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
|
|||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={saving || !selected || !form.quantity || !form.price || !form.account_id}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue