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:
megaproxy 2026-04-22 23:06:41 +00:00
parent 26e2a055db
commit cdc1e67321
9 changed files with 424 additions and 70 deletions

View file

@ -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"