feat(ui): change a post's rule from the month you are looking at
The natural moment to decide "15% from July" is while looking at July. The control writes the same setRuleFrom action the history editor uses, so the two surfaces cannot drift apart. A month where a version begins gets its own badge, distinct from a one-month override, so a step in the numbers has a visible cause; a month can carry both. The editor is keyed on post and month so switching posts remounts it — without that, React reuses the instance and useState keeps the previous post's values. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e3023d0322
commit
f94e45871d
@@ -2,6 +2,7 @@ import { Fragment } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatMoney, formatSignedMoney } from "../format.ts";
|
||||
import { ruleAt } from "../../domain/allocation.ts";
|
||||
import type { MonthPostRow } from "../../domain/views.ts";
|
||||
import type { PostMonthFigures } from "../../domain/fold.ts";
|
||||
import type { Currency, MonthId } from "../../domain/types.ts";
|
||||
@@ -10,6 +11,8 @@ interface Props {
|
||||
monthId: MonthId;
|
||||
baseCurrency: Currency;
|
||||
rows: MonthPostRow[];
|
||||
/** Omitted on read-only mounts; only the month view offers rule editing. */
|
||||
onChangeRule?: (postId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,7 +60,7 @@ function CarryMeter({ figures }: { figures: PostMonthFigures }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function PostTable({ monthId, baseCurrency, rows }: Props) {
|
||||
export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
@@ -87,11 +90,33 @@ export function PostTable({ monthId, baseCurrency, rows }: Props) {
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-2"
|
||||
title="This month overrides the standing rule"
|
||||
title="This month overrides the rule otherwise in effect"
|
||||
>
|
||||
overridden
|
||||
</Badge>
|
||||
)}
|
||||
{/* A month can carry BOTH badges: a version starting in a
|
||||
month the user also overrode is unusual but legal, and
|
||||
hiding either would obscure why the number is what it
|
||||
is. The override still wins the allocation. */}
|
||||
{ruleAt(post, monthId)?.from === monthId && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-2"
|
||||
title="This post's allocation rule changes from this month"
|
||||
>
|
||||
rule changes here
|
||||
</Badge>
|
||||
)}
|
||||
{onChangeRule && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 text-xs text-muted-foreground underline decoration-dotted"
|
||||
onClick={() => onChangeRule(post.id)}
|
||||
>
|
||||
change from here
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className={`font-money py-2 text-right ${
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -6,7 +7,9 @@ import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { useMutate } from "../hooks/useMutate.ts";
|
||||
import { monthView } from "../../domain/views.ts";
|
||||
import { addMonths } from "../../domain/months.ts";
|
||||
import { setIncome, deletePurchase } from "../../store/actions.ts";
|
||||
import { setIncome, deletePurchase, setRuleFrom } from "../../store/actions.ts";
|
||||
import { ruleAt } from "../../domain/allocation.ts";
|
||||
import type { MonthId, Post, Rule } from "../../domain/types.ts";
|
||||
import { sliceAmountForMonth } from "../../domain/charges.ts";
|
||||
import { formatMoney, formatSignedMoney } from "../format.ts";
|
||||
import { PostTable } from "../components/PostTable.tsx";
|
||||
@@ -18,6 +21,10 @@ export function MonthRoute() {
|
||||
const { mutate, error, clearError } = useMutate();
|
||||
const view = monthView(dataset, monthId);
|
||||
const base = dataset.settings.baseCurrency;
|
||||
const [changingRuleFor, setChangingRuleFor] = useState<string | null>(null);
|
||||
const changingPost = changingRuleFor
|
||||
? (dataset.posts.find((p) => p.id === changingRuleFor) ?? null)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
@@ -82,7 +89,25 @@ export function MonthRoute() {
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<PostTable monthId={monthId} baseCurrency={base} rows={view.rows} />
|
||||
<PostTable
|
||||
monthId={monthId}
|
||||
baseCurrency={base}
|
||||
rows={view.rows}
|
||||
onChangeRule={setChangingRuleFor}
|
||||
/>
|
||||
|
||||
{changingPost && (
|
||||
<RuleFromMonth
|
||||
// Keyed so switching posts (or months) remounts the editor. Without
|
||||
// it React reuses the instance and useState keeps the PREVIOUS
|
||||
// post's kind and value, silently pre-filling the wrong rule.
|
||||
key={`${changingPost.id}:${monthId}`}
|
||||
post={changingPost}
|
||||
monthId={monthId}
|
||||
initial={ruleAt(changingPost, monthId)?.rule}
|
||||
onDone={() => setChangingRuleFor(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -132,3 +157,80 @@ export function MonthRoute() {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "15% from July" is a decision made while looking at July. This writes the
|
||||
* same `setRuleFrom` action the Settings history editor uses, so the two
|
||||
* surfaces cannot drift apart.
|
||||
*/
|
||||
function RuleFromMonth({
|
||||
post,
|
||||
monthId,
|
||||
initial,
|
||||
onDone,
|
||||
}: {
|
||||
post: Post;
|
||||
monthId: MonthId;
|
||||
initial: Rule | undefined;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { mutate } = useMutate();
|
||||
// Pre-filled with what is currently in effect, so nudging 10% to 15% is an
|
||||
// edit rather than re-entry. Empty when the post has no rule yet. Held as
|
||||
// text so the field can be cleared mid-edit; parsed once, on apply.
|
||||
const [kind, setKind] = useState<Rule["kind"]>(initial?.kind ?? "percentOfIncome");
|
||||
const [value, setValue] = useState<string>(
|
||||
initial === undefined
|
||||
? ""
|
||||
: String(initial.kind === "fixed" ? initial.amount.amount : initial.percent),
|
||||
);
|
||||
|
||||
const amount = Number(value);
|
||||
const valueOk = value.trim() !== "" && Number.isFinite(amount);
|
||||
|
||||
function apply() {
|
||||
const rule: Rule =
|
||||
kind === "fixed"
|
||||
? { kind: "fixed", amount: { amount, currency: post.currency } }
|
||||
: { kind: "percentOfIncome", percent: amount };
|
||||
mutate((draft) => setRuleFrom(draft, post.id, monthId, rule));
|
||||
onDone();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-2 rounded border p-3 text-sm">
|
||||
<span>
|
||||
{post.name}: allocate from <span className="font-money">{monthId}</span> onward
|
||||
</span>
|
||||
<select
|
||||
className="h-8 rounded border bg-background px-1 text-xs"
|
||||
aria-label="Rule kind"
|
||||
value={kind}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value as Rule["kind"];
|
||||
setKind(next);
|
||||
}}
|
||||
>
|
||||
<option value="percentOfIncome">% of income</option>
|
||||
<option value="fixed">fixed amount</option>
|
||||
</select>
|
||||
<Input
|
||||
className="font-money h-8 w-24"
|
||||
type="number"
|
||||
step="0.01"
|
||||
aria-label={kind === "fixed" ? `Amount in ${post.currency}` : "Percent of income"}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
setValue(next);
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" disabled={!valueOk} onClick={apply}>
|
||||
Apply
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onDone}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user