Add fair split calculator UI
This commit is contained in:
parent
1649fb8e77
commit
69a88163dc
9
app/api/households/[id]/fair-split/route.ts
Normal file
9
app/api/households/[id]/fair-split/route.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
return proxyRequest(req, `households/${params.id}/fair-split`);
|
||||
}
|
||||
@ -125,6 +125,31 @@ type DashboardData = {
|
||||
recentTransactions: RecentTransaction[];
|
||||
};
|
||||
|
||||
type FairSplitResult = {
|
||||
expenseAmount: number;
|
||||
splitMode: "equal" | "income_weighted" | "custom";
|
||||
mineMonthlyIncome: number;
|
||||
yoursMonthlyIncome: number;
|
||||
totalIncome: number;
|
||||
incomeShares: {
|
||||
mine: number;
|
||||
yours: number;
|
||||
};
|
||||
split: {
|
||||
minePercent: number;
|
||||
yoursPercent: number;
|
||||
mineAmount: number;
|
||||
yoursAmount: number;
|
||||
};
|
||||
transactionDefaults: {
|
||||
attribution: "ours";
|
||||
splitMode: "equal" | "custom";
|
||||
splitMinePercent: number;
|
||||
splitYoursPercent: number;
|
||||
};
|
||||
rationale: string;
|
||||
};
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
@ -173,6 +198,15 @@ export default function HouseholdSettingsPage() {
|
||||
priority: "medium" as "low" | "medium" | "high",
|
||||
});
|
||||
const [goalContributions, setGoalContributions] = useState<Record<string, string>>({});
|
||||
const [fairSplitDraft, setFairSplitDraft] = useState({
|
||||
expenseAmount: "",
|
||||
mineMonthlyIncome: "",
|
||||
yoursMonthlyIncome: "",
|
||||
splitMode: "income_weighted" as "equal" | "income_weighted" | "custom",
|
||||
customMinePercent: "50",
|
||||
});
|
||||
const [fairSplitResult, setFairSplitResult] = useState<FairSplitResult | null>(null);
|
||||
const [fairSplitStatus, setFairSplitStatus] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [status, setStatus] = useState("");
|
||||
@ -349,6 +383,36 @@ export default function HouseholdSettingsPage() {
|
||||
await loadInvites(selectedId);
|
||||
};
|
||||
|
||||
const calculateFairSplit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selectedId) return;
|
||||
const expenseAmount = Number.parseFloat(fairSplitDraft.expenseAmount);
|
||||
if (!Number.isFinite(expenseAmount) || expenseAmount <= 0) {
|
||||
setFairSplitStatus("Enter an expense amount greater than zero.");
|
||||
return;
|
||||
}
|
||||
setFairSplitStatus("Calculating fair split...");
|
||||
const res = await apiFetch<FairSplitResult>(`/api/households/${selectedId}/fair-split`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
expenseAmount,
|
||||
mineMonthlyIncome: Number.parseFloat(fairSplitDraft.mineMonthlyIncome || "0"),
|
||||
yoursMonthlyIncome: Number.parseFloat(fairSplitDraft.yoursMonthlyIncome || "0"),
|
||||
splitMode: fairSplitDraft.splitMode,
|
||||
customMinePercent: fairSplitDraft.splitMode === "custom"
|
||||
? Number.parseFloat(fairSplitDraft.customMinePercent)
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
if (res.error) {
|
||||
setFairSplitResult(null);
|
||||
setFairSplitStatus(res.error.message ?? "Unable to calculate split.");
|
||||
return;
|
||||
}
|
||||
setFairSplitResult(res.data);
|
||||
setFairSplitStatus("");
|
||||
};
|
||||
|
||||
const updateMemberAccess = async (member: HouseholdMember, payload: { role?: string; status?: string }) => {
|
||||
if (!selectedId) return;
|
||||
const res = await apiFetch<HouseholdMember>(`/api/households/${selectedId}/members/${member.id}`, {
|
||||
@ -500,6 +564,92 @@ export default function HouseholdSettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel rounded-2xl p-6 shadow-sm">
|
||||
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Fair split calculator</p>
|
||||
<h2 className="mt-2 text-xl font-bold text-foreground">Split shared expenses by income or custom share</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Calculate a household split before applying the same percentages to a shared transaction.
|
||||
</p>
|
||||
{fairSplitResult ? (
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-border bg-background/40 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Mine</p>
|
||||
<p className="mt-2 text-2xl font-bold text-foreground">{formatMoney(fairSplitResult.split.mineAmount)}</p>
|
||||
<p className="text-sm text-muted-foreground">{fairSplitResult.split.minePercent}% share</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-background/40 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Yours</p>
|
||||
<p className="mt-2 text-2xl font-bold text-foreground">{formatMoney(fairSplitResult.split.yoursAmount)}</p>
|
||||
<p className="text-sm text-muted-foreground">{fairSplitResult.split.yoursPercent}% share</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground sm:col-span-2">
|
||||
{fairSplitResult.rationale} Transaction default: {fairSplitResult.transactionDefaults.splitMode} split with ours attribution.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<form onSubmit={calculateFairSplit} className="grid gap-3 md:grid-cols-2">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={fairSplitDraft.expenseAmount}
|
||||
onChange={(event) => setFairSplitDraft((prev) => ({ ...prev, expenseAmount: event.target.value }))}
|
||||
placeholder="Expense amount"
|
||||
className={inputClass}
|
||||
/>
|
||||
<select
|
||||
value={fairSplitDraft.splitMode}
|
||||
onChange={(event) => setFairSplitDraft((prev) => ({ ...prev, splitMode: event.target.value as "equal" | "income_weighted" | "custom" }))}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="income_weighted">Income-weighted</option>
|
||||
<option value="equal">Equal 50/50</option>
|
||||
<option value="custom">Custom percent</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={fairSplitDraft.mineMonthlyIncome}
|
||||
onChange={(event) => setFairSplitDraft((prev) => ({ ...prev, mineMonthlyIncome: event.target.value }))}
|
||||
placeholder="Mine monthly income"
|
||||
className={inputClass}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={fairSplitDraft.yoursMonthlyIncome}
|
||||
onChange={(event) => setFairSplitDraft((prev) => ({ ...prev, yoursMonthlyIncome: event.target.value }))}
|
||||
placeholder="Yours monthly income"
|
||||
className={inputClass}
|
||||
/>
|
||||
{fairSplitDraft.splitMode === "custom" ? (
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
value={fairSplitDraft.customMinePercent}
|
||||
onChange={(event) => setFairSplitDraft((prev) => ({ ...prev, customMinePercent: event.target.value }))}
|
||||
placeholder="Mine percent"
|
||||
className={inputClass}
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 md:col-span-2"
|
||||
>
|
||||
Calculate split
|
||||
</button>
|
||||
{fairSplitStatus ? <p className="text-sm text-muted-foreground md:col-span-2">{fairSplitStatus}</p> : null}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
|
||||
<div className="glass-panel rounded-2xl p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user