Files
finance-app/src/components/split-modal.tsx
T
siddharthd fc9ffaba25 chore(ci): clear the lint debt and make lint blocking (#79)
CI ran lint with continue-on-error because 23 errors predated it. All are
fixed, so the flag is gone and a typecheck step is added beside it — nothing
else in CI compiles the test files, so a type error in a fixture could only
ever be found on a developer's machine.

Real fixes, no blanket disables and no suppressions:

- react-hooks/set-state-in-effect (6): each was "reset or seed state when an
  input changes", so each becomes a state adjustment during render behind a
  previous-value guard — the documented React pattern for it. Two were also
  latent bugs. transactions/page.tsx depended on `others`, rebuilt by .filter()
  every render, so the effect already ran every render, one commit late.
  shared/page.tsx depended on the `tagIds` array identity, so the row window
  was reset more often than "changing any filter" claims — including after
  "Show more", the one interaction it must not undo; the key now joins by
  value. csv-import-modal reads localStorage once at mount, which is a lazy
  useState initializer, not an effect (SSR-guarded, though the modal is
  mounted from client state and never prerenders).
- react-hooks/static-components (3): SortIcon was declared inside SharedPage,
  making it a new component type on every render. Hoisted to module scope with
  the sort state passed as props.
- @typescript-eslint/no-explicit-any (9): the dynamically imported route
  handlers in two integration tests. Typed as their real signatures with one
  `as unknown as NextRequest` cast at the fixture boundary, so the call sites
  and response bodies get checked instead of opted out.
- react/no-unescaped-entities (5): exact-equivalent entities (', "),
  so no rendered text changes.

Also fixes the one pre-existing tsc error (unit/order-ingestion.test.ts:511):
`options` is optional, and `?? 0` keeps the assertion failing on an absent
list rather than silencing it with a non-null bang.

9 warnings remain (no-unused-vars, exhaustive-deps). eslint exits 0 on
warnings, so lint blocks on errors only — deliberately, since none of the
remaining warnings should stop a push.
2026-08-21 00:52:14 +10:00

239 lines
8.7 KiB
TypeScript

"use client";
import { useState } from "react";
import { useParticipants, useSetSplits, useTransactionSplits, useBulkAction, useCreateRule, useCurrentUser } from "@/lib/hooks";
interface Split {
participant_id: number;
share_percent: number;
}
interface Props {
transactionId?: number;
transactionIds?: number[];
amount?: number;
description: string;
merchant?: string;
onClose: () => void;
}
export function SplitModal({ transactionId, transactionIds, amount, description, merchant, onClose }: Props) {
const isBulk = !!transactionIds && transactionIds.length > 0;
const singleId = transactionId ?? 0;
const { data: participants } = useParticipants();
const { data: currentUser } = useCurrentUser();
const { data: existingSplits } = useTransactionSplits(isBulk ? 0 : singleId);
const setSplits = useSetSplits();
const bulkAction = useBulkAction();
const createRule = useCreateRule();
const [splits, setSplitsState] = useState<Split[]>([]);
const [error, setError] = useState("");
const [saveAsRule, setSaveAsRule] = useState(false);
const [ruleSaved, setRuleSaved] = useState(false);
// Initialise: bulk always defaults to 100% Me; single loads existing splits.
//
// Seeded during render behind a previous-value guard rather than in an
// effect. Both queries resolve after the first paint, so as an effect this
// rendered an empty split table and then replaced it — and, worse, it re-ran
// on every identity change of `participants`/`existingSplits`, which would
// discard edits in progress if either query refetched while the modal was
// open. Keying on the inputs that actually define the seed fixes both.
const seedKey =
!participants || participants.length === 0 || !currentUser
? null
: `${isBulk ? "bulk" : singleId}:${currentUser.id}:${
existingSplits ? JSON.stringify(existingSplits) : "none"
}`;
const [seededFrom, setSeededFrom] = useState<string | null>(null);
if (seedKey !== null && seedKey !== seededFrom) {
setSeededFrom(seedKey);
setSplitsState(
!isBulk && existingSplits && existingSplits.length > 0
? existingSplits.map((s: { participant_id: number; share_percent: number }) => ({
participant_id: s.participant_id,
share_percent: Number(s.share_percent),
}))
: [{ participant_id: currentUser!.id, share_percent: 100 }]
);
}
const total = splits.reduce((sum, s) => sum + s.share_percent, 0);
const toggleParticipant = (id: number) => {
setSplitsState((prev) => {
const exists = prev.find((s) => s.participant_id === id);
if (exists) {
return prev.filter((s) => s.participant_id !== id);
}
// Add with equal split
const count = prev.length + 1;
const equal = Math.floor(100 / count);
const remainder = 100 - equal * count;
return [
...prev.map((s, i) => ({ ...s, share_percent: equal + (i === 0 ? remainder : 0) })),
{ participant_id: id, share_percent: equal },
];
});
};
const updateShare = (id: number, value: number) => {
setSplitsState((prev) =>
prev.map((s) => (s.participant_id === id ? { ...s, share_percent: value } : s))
);
};
const splitEvenly = () => {
if (splits.length === 0) return;
const each = Math.floor(100 / splits.length);
const remainder = 100 - each * splits.length;
setSplitsState((prev) =>
prev.map((s, i) => ({ ...s, share_percent: each + (i === 0 ? remainder : 0) }))
);
};
const isPending = isBulk ? bulkAction.isPending : setSplits.isPending;
const handleSave = async () => {
setError("");
if (Math.abs(total - 100) > 0.01) {
setError(`Shares must sum to 100% (currently ${total.toFixed(1)}%)`);
return;
}
try {
if (isBulk) {
await bulkAction.mutateAsync({ action: "split", ids: transactionIds!, splits });
} else {
await setSplits.mutateAsync({ transactionId: singleId, splits });
}
if (saveAsRule && !isBulk) {
const matchValue = merchant || (description.split(" ")[0] ?? description);
await createRule.mutateAsync({
name: `Split: ${merchant || description}`,
conditions: [{ field: "merchant_normalized", operator: "contains", value: matchValue }],
actions: { apply_split: splits },
enabled: true,
priority: 0,
});
setRuleSaved(true);
setTimeout(onClose, 1200);
} else {
onClose();
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save splits");
}
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
<div
className="bg-zinc-900 border border-zinc-700 rounded-xl p-6 w-full max-w-md mx-4 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold mb-1">
{isBulk ? `Split ${transactionIds!.length} Transactions` : "Split Transaction"}
</h3>
<p className="text-sm text-zinc-400 mb-4 truncate">{description}</p>
{!isBulk && amount !== undefined && (
<p className="text-2xl font-mono font-semibold mb-6">
${Number(amount).toFixed(2)}
</p>
)}
{/* Participant toggles */}
<div className="space-y-3 mb-4">
{participants?.map((p) => {
const split = splits.find((s) => s.participant_id === p.id);
const active = !!split;
return (
<div key={p.id} className="flex items-center gap-3">
<button
onClick={() => toggleParticipant(p.id)}
className={`w-8 h-8 rounded-full text-sm font-medium flex-shrink-0 transition-colors ${
active
? "bg-blue-600 text-white"
: "bg-zinc-800 text-zinc-500 hover:bg-zinc-700"
}`}
>
{p.name.charAt(0).toUpperCase()}
</button>
<span className="flex-1 text-sm">{p.name}</span>
{active && (
<div className="flex items-center gap-2">
<input
type="range"
min={1}
max={99}
value={split.share_percent}
onChange={(e) => updateShare(p.id, Number(e.target.value))}
className="w-24 accent-blue-600"
/>
<span className="w-12 text-right text-sm font-mono">
{split.share_percent}%
</span>
{!isBulk && amount !== undefined && (
<span className="w-20 text-right text-sm text-zinc-400 font-mono">
${((amount * split.share_percent) / 100).toFixed(2)}
</span>
)}
</div>
)}
</div>
);
})}
</div>
{/* Total indicator */}
<div className="flex items-center justify-between mb-4 text-sm">
<span className={`font-mono ${Math.abs(total - 100) > 0.01 ? "text-red-400" : "text-green-400"}`}>
Total: {total.toFixed(1)}%
</span>
<button
onClick={splitEvenly}
className="text-blue-400 hover:text-blue-300 text-xs"
>
Split evenly
</button>
</div>
{error && <p className="text-red-400 text-sm mb-3">{error}</p>}
{ruleSaved && (
<p className="text-green-400 text-sm mb-3">Rule saved — future matching transactions will be split the same way.</p>
)}
{!isBulk && (
<label className="flex items-center gap-2 text-sm text-zinc-400 mb-3 cursor-pointer select-none">
<input
type="checkbox"
checked={saveAsRule}
onChange={(e) => setSaveAsRule(e.target.checked)}
className="accent-blue-500"
/>
Also save as rule for <span className="text-zinc-200 font-medium">{merchant || description.split(" ")[0]}</span>
</label>
)}
<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-sm transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={isPending || Math.abs(total - 100) > 0.01}
className="flex-1 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
>
{isPending ? "Saving..." : "Save splits"}
</button>
</div>
</div>
</div>
);
}