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

321 lines
11 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,
TextFieldRoot,
TextFieldTextarea,
} from "@seed-design/react";
import { useTransactions, useCategories } from "@/api/hooks/reads";
import { useCategorize, useRenameTxn, useSetNote } 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 } = useTransactions();
const { data: categories = [] } = useCategories();
const categorize = useCategorize();
const renameTxn = useRenameTxn();
const setNoteMutation = useSetNote();
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 [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("");
useEffect(() => {
setAssignedCategory(null);
setDisplayTitle(null);
setNoteOverride(null);
setRenaming(false);
setEditingNote(false);
}, [id]);
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;
const amountRaw = hiddenAmounts ? MASKED : tugrikRaw(txn.amount);
const signedAmount = hiddenAmounts ? MASKED : `${income ? "+" : ""}${tugrikRaw(txn.amount)}`;
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`.
}
}
}
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}
last={!canNote}
/>
{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
last
onClick={() => {
setNoteDraft(note);
setEditingNote(true);
}}
/>
))}
</Card>
<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>
</div>
);
}
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>
);
}