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[];
|
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", {
|
const money = new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
@ -173,6 +198,15 @@ export default function HouseholdSettingsPage() {
|
|||||||
priority: "medium" as "low" | "medium" | "high",
|
priority: "medium" as "low" | "medium" | "high",
|
||||||
});
|
});
|
||||||
const [goalContributions, setGoalContributions] = useState<Record<string, string>>({});
|
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 [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
@ -349,6 +383,36 @@ export default function HouseholdSettingsPage() {
|
|||||||
await loadInvites(selectedId);
|
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 }) => {
|
const updateMemberAccess = async (member: HouseholdMember, payload: { role?: string; status?: string }) => {
|
||||||
if (!selectedId) return;
|
if (!selectedId) return;
|
||||||
const res = await apiFetch<HouseholdMember>(`/api/households/${selectedId}/members/${member.id}`, {
|
const res = await apiFetch<HouseholdMember>(`/api/households/${selectedId}/members/${member.id}`, {
|
||||||
@ -500,6 +564,92 @@ export default function HouseholdSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
|
||||||
<div className="glass-panel rounded-2xl p-6 shadow-sm">
|
<div className="glass-panel rounded-2xl p-6 shadow-sm">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user