321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
"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>
|
||
);
|
||
}
|