diff --git a/src/features/accounting/TransactionDetail.tsx b/src/features/accounting/TransactionDetail.tsx index 5b4d5ff..e2c2b49 100644 --- a/src/features/accounting/TransactionDetail.tsx +++ b/src/features/accounting/TransactionDetail.tsx @@ -16,9 +16,21 @@ import { Skeleton, TextFieldRoot, TextFieldTextarea, + TextFieldInput, + SwitchRoot, + SwitchControl, + SwitchThumb, + BottomSheetRoot, + BottomSheetBackdrop, + BottomSheetPositioner, + BottomSheetContent, + BottomSheetHeader, + BottomSheetTitle, + BottomSheetBody, + BottomSheetFooter, } from "@seed-design/react"; -import { useTransactions, useCategories } from "@/api/hooks/reads"; -import { useCategorize, useRenameTxn, useSetNote } from "@/api/hooks/mutations"; +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"; @@ -103,9 +115,12 @@ 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]); @@ -117,6 +132,7 @@ export function TransactionDetail({ id }: TransactionDetailProps) { const [assignedCategory, setAssignedCategory] = useState(null); const [displayTitle, setDisplayTitle] = useState(null); const [noteOverride, setNoteOverride] = useState(null); + const [subscriptionOverride, setSubscriptionOverride] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); const [renaming, setRenaming] = useState(false); @@ -124,13 +140,16 @@ export function TransactionDetail({ id }: TransactionDetailProps) { 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) { @@ -158,10 +177,23 @@ export function TransactionDetail({ id }: TransactionDetailProps) { 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; @@ -188,6 +220,33 @@ export function TransactionDetail({ id }: TransactionDetailProps) { } } + /** 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 (
@@ -237,11 +296,7 @@ export function TransactionDetail({ id }: TransactionDetailProps) { {txn.balanceAfter != null && ( )} - + {canNote && (editingNote ? (
@@ -271,15 +326,39 @@ export function TransactionDetail({ id }: TransactionDetailProps) { label={s.detail.note} value={note || s.detail.noteAdd} chevron - last onClick={() => { setNoteDraft(note); setEditingNote(true); }} /> ))} +
+ + {s.detailActions.subscriptionLabel} + +
+ + {isSubscription ? s.detailActions.subscriptionActive : s.detailActions.subscriptionInactive} + + + + + + +
+
+ {!income && ( + setConvertOpen(true)}> + {s.detailActions.convertToLending} + + )} + + + {convertOpen && ( + setConvertOpen(false)} + onSave={convertToLending} + /> + )}
); } +/** + * 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; +}) { + 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 ( + { if (!next) onClose(); }}> + + + + + {s.detailActions.lendingSheetTitle} + + + + + + + + + + + + + {s.detailActions.cancel} + + onSave({ person: person.trim(), amount, lentOn })} + > + {s.detailActions.save} + + + + + + ); +} + function BackButton({ onClick }: { onClick: () => void }) { return ( + @@ -596,6 +621,7 @@ function AddLoanSheet({ lentOn, dueOn: dueOn || undefined, note: note.trim() || undefined, + txnId: linkedTxnId, }) } > @@ -605,5 +631,20 @@ function AddLoanSheet({ + + { + setLinkedTxnId(txn.txnId ?? undefined); + setAmount(txn.amount); + setLentOn(txn.date.slice(0, 10)); + if (!person.trim()) setPerson(txn.title); + setPickerOpen(false); + }} + /> + ); } diff --git a/src/features/assets/LendingDetail.tsx b/src/features/assets/LendingDetail.tsx index 4f2865f..4ca9a09 100644 --- a/src/features/assets/LendingDetail.tsx +++ b/src/features/assets/LendingDetail.tsx @@ -23,6 +23,8 @@ import { tugrikRaw } from "@/ds/money"; import { assetsStrings as s } from "./strings"; import { ConfirmDialog } from "./ConfirmDialog"; import { DetailHeader } from "./DetailHeader"; +import { TransactionPickerSheet } from "./TransactionPickerSheet"; +import { linkedTxnIdsOf } from "./lendingLinks"; type LendingRepayment = Lending["repayments"][number]; @@ -57,7 +59,9 @@ export function LendingDetail({ id }: LendingDetailProps) { const [deletingRepayment, setDeletingRepayment] = React.useState(null); const [confirmDeleteEntry, setConfirmDeleteEntry] = React.useState(false); - const loan: Lending | undefined = (lending.data ?? []).find((l) => l.id === id); + const loans = lending.data ?? []; + const loan: Lending | undefined = loans.find((l) => l.id === id); + const linkedTxnIds = linkedTxnIdsOf(loans); if (lending.isLoading && !loan) { return ( @@ -95,19 +99,22 @@ export function LendingDetail({ id }: LendingDetailProps) {
{s.lending.total} {tugrikRaw(loan.principal)}
-
- {statusLabel(loan)} +
+
+ {statusLabel(loan)} +
+ {loan.txnId != null && loan.txnId !== 0 && ( + {s.lending.linkedBadge} + )}
@@ -127,7 +134,12 @@ export function LendingDetail({ id }: LendingDetailProps) {
-
{tugrikRaw(r.amount)}
+
+ {tugrikRaw(r.amount)} + {r.txnId != null && r.txnId !== 0 && ( + {s.lending.linkedBadge} + )} +
{r.paidOn} {r.note ? ` · ${r.note}` : ""} @@ -160,6 +172,7 @@ export function LendingDetail({ id }: LendingDetailProps) { {addRepaymentOpen && ( setAddRepaymentOpen(false)} saving={mutations.addRepayment.isPending} onSave={async (values) => { @@ -195,21 +208,26 @@ export function LendingDetail({ id }: LendingDetailProps) { } function AddRepaymentSheet({ + linkedTxnIds, onClose, onSave, saving, }: { + linkedTxnIds: Set; onClose: () => void; - onSave: (values: { amount: string; paidOn: string; note?: string }) => void | Promise; + onSave: (values: { amount: string; paidOn: string; note?: string; txnId?: number }) => void | Promise; saving: boolean; }) { const [amount, setAmount] = React.useState(""); const [paidOn, setPaidOn] = React.useState(todayISO()); const [note, setNote] = React.useState(""); + const [linkedTxnId, setLinkedTxnId] = React.useState(undefined); + const [pickerOpen, setPickerOpen] = React.useState(false); const canSave = Number(amount) > 0 && !saving; return ( + <> { if (!next) onClose(); }}> @@ -234,6 +252,16 @@ function AddRepaymentSheet({ + @@ -244,7 +272,7 @@ function AddRepaymentSheet({ style={{ flex: 1 }} disabled={!canSave} loading={saving} - onClick={() => onSave({ amount, paidOn, note: note.trim() || undefined })} + onClick={() => onSave({ amount, paidOn, note: note.trim() || undefined, txnId: linkedTxnId })} > {s.common.save} @@ -252,5 +280,19 @@ function AddRepaymentSheet({ + + { + setLinkedTxnId(txn.txnId ?? undefined); + setAmount(txn.amount); + setPaidOn(txn.date.slice(0, 10)); + setPickerOpen(false); + }} + /> + ); } diff --git a/src/features/assets/TransactionPickerSheet.tsx b/src/features/assets/TransactionPickerSheet.tsx new file mode 100644 index 0000000..694f1b1 --- /dev/null +++ b/src/features/assets/TransactionPickerSheet.tsx @@ -0,0 +1,138 @@ +"use client"; + +import * as React from "react"; +import { + BottomSheetRoot, + BottomSheetBackdrop, + BottomSheetPositioner, + BottomSheetContent, + BottomSheetHeader, + BottomSheetTitle, + BottomSheetCloseButton, + BottomSheetBody, + TextFieldRoot, + TextFieldInput, + ListRoot, + ListItem, + Icon, +} from "@seed-design/react"; +import { useTransactions, todayLocalDate } from "@/api/hooks/reads"; +import type { Txn } from "@/api/schemas"; +import { tugrikRaw } from "@/ds/money"; +import { assetsStrings as s } from "./strings"; + +const closeSvg = ( + + + +); + +export interface TransactionPickerSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** "expense" for a new loan, "income" for a repayment — matches + * `TransactionPickerView`'s direction filter on iOS. */ + direction: "income" | "expense"; + /** Transaction ids already linked to some loan/repayment, so already-linked + * rows can carry a "Холбоотой" hint (mirrors `LendingModel.linkedTxnIds`). */ + linkedTxnIds?: Set; + onPick: (txn: Txn) => void; +} + +/** + * A searchable bottom sheet of the user's transactions, filtered to one + * direction, for linking a lending entry or repayment to the real transaction + * that created it. Ports `TransactionPickerView.swift` + `LendingAutofill.swift` + * (the filtering/candidate logic lives inline below, small enough not to need + * its own module). + */ +export function TransactionPickerSheet({ open, onOpenChange, direction, linkedTxnIds, onPick }: TransactionPickerSheetProps) { + const [search, setSearch] = React.useState(""); + + // Pull ~1 year so older loans/repayments stay linkable, matching iOS. + const from = React.useMemo(() => { + const d = new Date(); + d.setFullYear(d.getFullYear() - 1); + return todayLocalDate(d); + }, []); + const to = React.useMemo(() => todayLocalDate(), []); + + const { data: transactions, isLoading } = useTransactions({ direction, from, to, limit: 500 }); + + const shown = React.useMemo(() => { + const q = search.trim().toLowerCase(); + return (transactions ?? []).filter((t) => { + // Pending holds (no stable txnId) can't be linked. + if (t.txnId == null || t.txnId === 0) return false; + if (!q) return true; + return t.title.toLowerCase().includes(q) || t.amount.toLowerCase().includes(q); + }); + }, [transactions, search]); + + return ( + + + + + + {s.lending.picker.title} + + + + + + + + + {isLoading ? ( +

+ ) : shown.length === 0 ? ( +

{s.lending.picker.empty}

+ ) : ( + + {shown.map((txn) => { + const linked = txn.txnId != null && linkedTxnIds?.has(txn.txnId); + return ( + + + + ); + })} + + )} +
+
+
+
+ ); +} diff --git a/src/features/assets/lendingLinks.ts b/src/features/assets/lendingLinks.ts new file mode 100644 index 0000000..e65c4d1 --- /dev/null +++ b/src/features/assets/lendingLinks.ts @@ -0,0 +1,17 @@ +import type { Lending } from "@/api/schemas"; + +/** + * Transaction ids already linked to some loan or repayment (for the picker's + * "already linked" hint). Drops the 0/nil "unlinked" sentinel. Ports + * `LendingModel.linkedTxnIds` (iOS) — pure, no networking. + */ +export function linkedTxnIdsOf(loans: Lending[]): Set { + const ids = new Set(); + for (const l of loans) { + if (l.txnId != null && l.txnId !== 0) ids.add(l.txnId); + for (const r of l.repayments) { + if (r.txnId != null && r.txnId !== 0) ids.add(r.txnId); + } + } + return ids; +} diff --git a/src/features/assets/strings.ts b/src/features/assets/strings.ts index 8483019..7041173 100644 --- a/src/features/assets/strings.ts +++ b/src/features/assets/strings.ts @@ -82,7 +82,9 @@ export const assetsStrings = { lentOn: "Өгсөн огноо", dueOn: "Төлөх огноо", note: "Тэмдэглэл", + linkTxn: "Гүйлгээ холбох", }, + linkedBadge: "Холбоотой", repayments: { title: "Төлөлтийн түүх", empty: "Төлөлт бүртгэгдээгүй", @@ -90,6 +92,11 @@ export const assetsStrings = { deleteTitle: "Төлөлт устгах уу?", deleteDescription: "Энэ төлөлтийг түүхээс хасна.", }, + picker: { + title: "Гүйлгээ сонгох", + search: "Хайх", + empty: "Гүйлгээ олдсонгүй", + }, }, accountDetail: { balance: "Үлдэгдэл",