mercury-web/src/features/accounting/TransactionDetail.tsx

493 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import {
DialogRoot,
DialogBackdrop,
DialogPositioner,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogAction,
Skeleton,
TextFieldRoot,
TextFieldTextarea,
TextFieldInput,
SwitchRoot,
SwitchControl,
SwitchThumb,
BottomSheetRoot,
BottomSheetBackdrop,
BottomSheetPositioner,
BottomSheetContent,
BottomSheetHeader,
BottomSheetTitle,
BottomSheetBody,
BottomSheetFooter,
} from "@seed-design/react";
import { useTransactions, useCategories, useSubscriptions } from "@/api/hooks/reads";
import { useCategorize, useRenameTxn, useSetNote, useSubscriptionMutations, useLendingMutations } from "@/api/hooks/mutations";
import { Card, MercuryButton, NameEdit } from "@/ds";
import { MASKED, tugrikRaw } from "@/ds/money";
import { accountingStrings as s } from "./strings";
import { CategorizeSheet } from "./CategorizeSheet";
import { findTxnByRouteId } from "./txnRoute";
import { useHiddenAmounts } from "./useHiddenAmounts";
export interface TransactionDetailProps {
id: string;
}
const ROW_BORDER: CSSProperties = { borderBottom: "1px solid var(--seed-color-border-neutral, #e5e5e5)" };
function formatDateTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}.${pad(d.getMonth() + 1)}.${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function DetailRow({
label,
value,
chevron,
bold,
last,
onClick,
}: {
label: string;
value: string;
chevron?: boolean;
bold?: boolean;
last?: boolean;
onClick?: () => void;
}) {
const wrapperStyle: CSSProperties = last ? {} : ROW_BORDER;
const body = (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: "12px 16px" }}>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)", flexShrink: 0 }}>{label}</span>
<span
style={{
fontSize: bold ? 14 : 13,
fontWeight: bold ? 700 : 400,
textAlign: "right",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{value}
</span>
{chevron && (
<span aria-hidden style={{ color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
</span>
)}
</div>
);
if (!onClick) return <div style={wrapperStyle}>{body}</div>;
return (
<button
type="button"
onClick={onClick}
style={{ all: "unset", display: "block", width: "100%", boxSizing: "border-box", cursor: "pointer", ...wrapperStyle }}
>
{body}
</button>
);
}
/**
* Transaction detail (ports `TransactionDetailView.swift`, narrowed to this
* task's scope): a key-value table plus categorize / rename / note edits.
* Categorize and rename both "learn a rule" server-side keyed on the
* merchant's match key (applies to all of that merchant's past + future
* transactions) — renaming shows a confirm dialog because of that broad
* effect; categorizing (like iOS) applies immediately.
*/
export function TransactionDetail({ id }: TransactionDetailProps) {
const router = useRouter();
const { data: transactions, isLoading } = useTransactions();
const { data: categories = [] } = useCategories();
const { data: subscriptions } = useSubscriptions();
const categorize = useCategorize();
const renameTxn = useRenameTxn();
const setNoteMutation = useSetNote();
const subscriptionMutations = useSubscriptionMutations();
const lendingMutations = useLendingMutations();
const hiddenAmounts = useHiddenAmounts();
const txn = useMemo(() => findTxnByRouteId(transactions ?? [], id), [transactions, id]);
// Local overrides so an edit reflects immediately, matching iOS's
// `assigned` / `displayTitle` @State — the server call runs in the
// background and the list refetch (via mutation `onSuccess` invalidation)
// reconciles afterwards.
const [assignedCategory, setAssignedCategory] = useState<string | null>(null);
const [displayTitle, setDisplayTitle] = useState<string | null>(null);
const [noteOverride, setNoteOverride] = useState<string | null>(null);
const [subscriptionOverride, setSubscriptionOverride] = useState<boolean | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [renaming, setRenaming] = useState(false);
const [pendingName, setPendingName] = useState<string | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [editingNote, setEditingNote] = useState(false);
const [noteDraft, setNoteDraft] = useState("");
const [convertOpen, setConvertOpen] = useState(false);
useEffect(() => {
setAssignedCategory(null);
setDisplayTitle(null);
setNoteOverride(null);
setSubscriptionOverride(null);
setRenaming(false);
setEditingNote(false);
setConvertOpen(false);
}, [id]);
if (isLoading && !txn) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<BackButton onClick={() => router.push("/accounting")} />
<Skeleton style={{ height: 300, width: "100%", borderRadius: "var(--seed-radius-r3)" }} />
</div>
);
}
if (!txn) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<BackButton onClick={() => router.push("/accounting")} />
<Card>
<p style={{ margin: 0, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>{s.detail.notFound}</p>
</Card>
</div>
);
}
const income = txn.direction === "income";
const category = assignedCategory ?? txn.category;
const title = displayTitle ?? txn.title;
const note = noteOverride ?? txn.note ?? "";
const canNote = txn.txnId != null && txn.txnId > 0;
// A settled row has a stable txnId; pending holds (nil/0) can't be linked
// to a lending entry or the subscription-detection reconciliation below.
const hasStableTxnId = txn.txnId != null && txn.txnId > 0;
const amountRaw = hiddenAmounts ? MASKED : tugrikRaw(txn.amount);
const signedAmount = hiddenAmounts ? MASKED : `${income ? "+" : ""}${tugrikRaw(txn.amount)}`;
// Whether this merchant is already marked as a subscription override, so the
// toggle opens in the right state (mirrors iOS's `.task` reconciliation
// against `api.recurring()`), keyed on the same match key categorize/rename
// already use for this merchant.
const subscriptionKey = (txn.matchKey ?? txn.title).toLowerCase().trim();
const detectedSubscription = (subscriptions?.subscriptions ?? []).some(
(sub) => (sub.matchKey ?? sub.label.toLowerCase()) === subscriptionKey,
);
const isSubscription = subscriptionOverride ?? detectedSubscription;
async function confirmRename() {
if (!pendingName) return;
const name = pendingName;
setDisplayTitle(name);
setPendingName(null);
try {
await renameTxn.mutateAsync({ matchKey: txn!.matchKey ?? txn!.title, name });
} catch {
// Best-effort, matches iOS's `renameMerchant` — leave the optimistic
// title in place; the next successful list load reconciles it.
}
}
async function saveNote() {
const trimmed = noteDraft.trim();
setNoteOverride(trimmed);
setEditingNote(false);
if (txn!.txnId != null) {
try {
await setNoteMutation.mutateAsync({ id: txn!.txnId, note: trimmed });
} catch {
// Best-effort, matches iOS's `saveNote`.
}
}
}
/** Toggle this merchant's subscription override (force-add / force-remove),
* mirroring iOS's `setSubscription`. */
async function toggleSubscription(next: boolean) {
setSubscriptionOverride(next);
try {
await subscriptionMutations.setSubscription.mutateAsync({
matchKey: txn!.matchKey ?? txn!.title,
active: next,
});
} catch {
// Best-effort — leave the optimistic toggle in place.
}
}
/** Create a lending entry from this expense, linked to the transaction when
* it has a stable id, then navigate to the new entry's detail page. */
async function convertToLending(values: { person: string; amount: string; lentOn: string }) {
const created = await lendingMutations.create.mutateAsync({
person: values.person,
amount: values.amount,
lentOn: values.lentOn,
txnId: hasStableTxnId ? (txn!.txnId as number) : undefined,
});
setConvertOpen(false);
router.push(created ? `/assets/lending/${created.id}` : "/assets");
}
if (renaming) {
return (
<div style={{ paddingTop: 24 }}>
<NameEdit
title={s.rename.title}
initial={title}
placeholder={s.rename.placeholder}
onCancel={() => setRenaming(false)}
onSave={(name) => {
setRenaming(false);
setPendingName(name);
setConfirmOpen(true);
}}
/>
</div>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
<BackButton onClick={() => router.push("/accounting")} />
<Card style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 4, minWidth: 0 }}>
<span style={{ fontSize: 16, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{title || category}
</span>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>{category}</span>
</div>
<span
style={{
fontSize: 18,
fontWeight: 700,
flexShrink: 0,
color: income ? "var(--mercury-success, #1e9e6b)" : "var(--mercury-critical, #e5484d)",
}}
>
{signedAmount}
</span>
</Card>
<Card style={{ padding: 0 }}>
<DetailRow label={s.detail.total} value={amountRaw} bold />
<DetailRow label={s.detail.date} value={formatDateTime(txn.date)} />
<DetailRow label={s.detail.category} value={category} chevron onClick={() => setPickerOpen(true)} />
<DetailRow label={s.detail.name} value={title} chevron onClick={() => setRenaming(true)} />
{txn.balanceAfter != null && (
<DetailRow label={s.detail.balance} value={hiddenAmounts ? MASKED : tugrikRaw(txn.balanceAfter)} />
)}
<DetailRow label={s.detail.type} value={income ? s.detail.income : s.detail.expense} />
{canNote &&
(editingNote ? (
<div style={{ padding: 12, display: "flex", flexDirection: "column", gap: 8 }}>
<TextFieldRoot value={noteDraft} onValueChange={setNoteDraft}>
<TextFieldTextarea
aria-label={s.detail.note}
placeholder={s.detail.noteAdd}
style={{ minHeight: 72 }}
/>
</TextFieldRoot>
<div style={{ display: "flex", gap: 8 }}>
<MercuryButton variant="secondary" style={{ flex: 1 }} onClick={() => setEditingNote(false)}>
{s.detail.noteCancel}
</MercuryButton>
<MercuryButton
variant="primary"
style={{ flex: 1 }}
loading={setNoteMutation.isPending}
onClick={saveNote}
>
{s.detail.noteSave}
</MercuryButton>
</div>
</div>
) : (
<DetailRow
label={s.detail.note}
value={note || s.detail.noteAdd}
chevron
onClick={() => {
setNoteDraft(note);
setEditingNote(true);
}}
/>
))}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: "12px 16px" }}>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
{s.detailActions.subscriptionLabel}
</span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
{isSubscription ? s.detailActions.subscriptionActive : s.detailActions.subscriptionInactive}
</span>
<SwitchRoot
checked={isSubscription}
onCheckedChange={toggleSubscription}
disabled={subscriptionMutations.setSubscription.isPending}
>
<SwitchControl>
<SwitchThumb />
</SwitchControl>
</SwitchRoot>
</div>
</div>
</Card>
{!income && (
<MercuryButton variant="secondary" onClick={() => setConvertOpen(true)}>
{s.detailActions.convertToLending}
</MercuryButton>
)}
<CategorizeSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
categories={categories}
selected={category}
onSelect={(cat) => {
setAssignedCategory(cat.name);
setPickerOpen(false);
categorize.mutate({
matchKey: txn.matchKey ?? txn.title,
category: cat.name,
kind: txn.direction === "income" ? "income" : "expense",
});
}}
/>
<DialogRoot open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogBackdrop />
<DialogPositioner>
<DialogContent>
<DialogHeader>
<DialogTitle>{s.rename.confirmTitle}</DialogTitle>
<DialogDescription>{s.rename.confirmDescription}</DialogDescription>
</DialogHeader>
<DialogFooter style={{ display: "flex", gap: 8 }}>
<DialogAction style={{ flex: 1 }}>{s.rename.confirmCancel}</DialogAction>
<DialogAction style={{ flex: 1 }} onClick={confirmRename}>
{s.rename.confirmSave}
</DialogAction>
</DialogFooter>
</DialogContent>
</DialogPositioner>
</DialogRoot>
{convertOpen && (
<ConvertToLendingSheet
initialPerson={title}
initialAmount={txn.amount}
initialLentOn={txn.date.slice(0, 10)}
saving={lendingMutations.create.isPending}
onClose={() => setConvertOpen(false)}
onSave={convertToLending}
/>
)}
</div>
);
}
/**
* Small pre-filled form (Card style="Зээл болгох") that turns an expense into a
* lending entry — ports the pre-fill in `TransactionDetailView.swift`'s
* `showMarkAsLending` (person/amount/date seeded via `LendingAutofill`),
* simplified to just those three editable fields per this task's scope.
*/
function ConvertToLendingSheet({
initialPerson,
initialAmount,
initialLentOn,
saving,
onClose,
onSave,
}: {
initialPerson: string;
initialAmount: string;
initialLentOn: string;
saving: boolean;
onClose: () => void;
onSave: (values: { person: string; amount: string; lentOn: string }) => void | Promise<void>;
}) {
const [person, setPerson] = useState(initialPerson);
const [amount, setAmount] = useState(initialAmount);
const [lentOn, setLentOn] = useState(initialLentOn);
const canSave = person.trim().length > 0 && Number(amount) > 0 && !saving;
return (
<BottomSheetRoot open onOpenChange={(next) => { if (!next) onClose(); }}>
<BottomSheetBackdrop />
<BottomSheetPositioner>
<BottomSheetContent>
<BottomSheetHeader>
<BottomSheetTitle>{s.detailActions.lendingSheetTitle}</BottomSheetTitle>
</BottomSheetHeader>
<BottomSheetBody style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<TextFieldRoot value={person} onValueChange={setPerson} name="convert-person">
<TextFieldInput placeholder={s.detailActions.person} aria-label={s.detailActions.person} autoFocus />
</TextFieldRoot>
<TextFieldRoot value={amount} onValueChange={setAmount} name="convert-amount">
<TextFieldInput
type="number"
inputMode="numeric"
placeholder={s.detailActions.amount}
aria-label={s.detailActions.amount}
/>
</TextFieldRoot>
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13 }}>
{s.detailActions.lentOn}
<input type="date" value={lentOn} onChange={(e) => setLentOn(e.target.value)} style={{ padding: 8, borderRadius: 8 }} />
</label>
</BottomSheetBody>
<BottomSheetFooter style={{ display: "flex", gap: 8 }}>
<MercuryButton variant="secondary" onClick={onClose} style={{ flex: 1 }}>
{s.detailActions.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
style={{ flex: 1 }}
disabled={!canSave}
loading={saving}
onClick={() => onSave({ person: person.trim(), amount, lentOn })}
>
{s.detailActions.save}
</MercuryButton>
</BottomSheetFooter>
</BottomSheetContent>
</BottomSheetPositioner>
</BottomSheetRoot>
);
}
function BackButton({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
aria-label={s.detail.back}
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer", alignSelf: "flex-start", padding: 0 }}
>
</button>
);
}